From 728288e1eae61e3d55e737c7d83031374e780533 Mon Sep 17 00:00:00 2001 From: Andrew Klatzke Date: Fri, 18 Sep 2026 11:22:35 -0800 Subject: [PATCH 1/4] fix(AIC-3382): support Bedrock configs in LangChain handlers Apply inference-profile prefixes before model construction and report the configured provider so Bedrock calls and telemetry stay aligned. Co-authored-by: Cursor --- examples/streaming.py | 3 +- .../handler.py | 29 +++- .../launchdarkly_ai_langchain_agents/spans.py | 12 +- .../langchain-agents/tests/test_handler.py | 126 ++++++++++++++++- .../handler.py | 29 +++- .../spans.py | 17 +-- .../langchain-messages/tests/test_handler.py | 133 +++++++++++++++++- tests/test_cross_handler_parity.py | 10 +- 8 files changed, 316 insertions(+), 43 deletions(-) diff --git a/examples/streaming.py b/examples/streaming.py index 2dec1c77..c6aaab33 100644 --- a/examples/streaming.py +++ b/examples/streaming.py @@ -37,10 +37,11 @@ async def run(key: str, user_input: str) -> None: sys.stdout.flush() else: # Final event — full response + normalised usage - sys.stdout.write("\n\n") + sys.stdout.write("\n") print("Usage:", json_pretty(event.get("usage"))) if event.get("judgeResults"): print("Judge results:", json_pretty(event["judgeResults"])) + sys.stdout.write("\n") def json_pretty(obj: object) -> str: diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index c2620c73..cd278dae 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -147,12 +147,34 @@ def _build_initial_messages( return messages +def _resolved_model_name(config: AiConfigRep, fallback_name: str = "") -> str: + """Bedrock ``model.region`` is an inference-profile prefix, prepended once.""" + model = config.get("model") or {} + name = str(model.get("name") or fallback_name) + provider = str((config.get("provider") or {}).get("name") or "").lower() + if provider != "bedrock": + return name + prefix = str(model.get("region") or "") + if not prefix or name.startswith(f"{prefix}."): + return name + return f"{prefix}.{name}" + + +def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: + """Shallow copy with a resolved Bedrock model name. Does not mutate *config*.""" + resolved = _resolved_model_name(config) + model = dict(config.get("model") or {}) + if model.get("name") == resolved: + return config + return {**config, "model": {**model, "name": resolved}} + + def _model_constructor_kwargs( config: AiConfigRep, fallback_name: str ) -> dict[str, Any]: raw = (config.get("model") or {}).get("parameters") parameters = dict(raw) if isinstance(raw, dict) else {} - parameters["model"] = (config.get("model") or {}).get("name") or fallback_name + parameters["model"] = _resolved_model_name(config, fallback_name) return parameters @@ -181,10 +203,11 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: async def _resolve_base_model(config: AiConfigRep, llm: Any) -> Any: + invocation = _config_for_model_call(config) if llm is None: - return _make_default_chat_model(config) + return _make_default_chat_model(invocation) if _is_model_factory(llm): - model = llm(config) + model = llm(invocation) if asyncio.iscoroutine(model): return await model return model diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py index eaa7ab00..3621de06 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py @@ -63,17 +63,13 @@ def model_name(config: AiConfigRep) -> str: def serving_provider(config: AiConfigRep) -> str: - """The provider that actually serves the model. + """The configured provider, lower-cased, for ``gen_ai.provider.name``. ``gen_ai.provider.name`` names who served the request, and its semconv enum has no - ``langchain`` member: LangChain is the framework, not the provider. This mirrors the choice - ``_make_default_chat_model`` makes, so the attribute agrees with the client that is really - used. It is a binary choice, not a passthrough of the configured name: the configured name - lower-cased if it equals ``anthropic``, otherwise ``openai``, no matter what else the config - names (Bedrock, Azure, Cohere, a typo, or nothing at all). + ``langchain`` member: LangChain is the framework, not the provider. Empty or missing + names fall back to ``openai``. ``gen_ai.system`` stays the literal ``langchain``. """ - provider = ((config.get("provider") or {}).get("name") or "").lower() - return "anthropic" if provider == "anthropic" else "openai" + return str((config.get("provider") or {}).get("name") or "openai").lower() # ─── Span starts ───────────────────────────────────────────────────────────── diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index a16b5eed..1d268930 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -778,18 +778,27 @@ async def test_gen_ai_provider_name_is_anthropic_when_config_names_it(self) -> N assert rec.root.attributes["gen_ai.provider.name"] == "anthropic" assert rec.root.attributes["gen_ai.system"] == "langchain" + @pytest.mark.parametrize( + ("provider_name", "expected"), + [ + ("OpenAI", "openai"), + ("Bedrock", "bedrock"), + ("Azure", "azure"), + ("Cohere", "cohere"), + ("Typo", "typo"), + ("", "openai"), + ], + ) @pytest.mark.asyncio - async def test_gen_ai_provider_name_falls_back_to_openai_for_anything_else( - self, + async def test_gen_ai_provider_name_is_the_configured_name( + self, provider_name: str, expected: str ) -> None: - # A binary choice, not a passthrough: Bedrock, Azure, Cohere, a typo, or nothing at all all - # report `openai`, because that mirrors which chat model class is really instantiated. ctx, rec = _recording() - cfg = {**BASE_CONFIG, "provider": {"name": "Bedrock"}} + cfg = {**BASE_CONFIG, "provider": {"name": provider_name}} llm = _FakeToolModel(replies=[_ai_message("hi")]) with ctx: await create_langchain_agents_handler(llm)(cfg, "q") - assert rec.root.attributes["gen_ai.provider.name"] == "openai" + assert rec.root.attributes["gen_ai.provider.name"] == expected @pytest.mark.asyncio async def test_response_model_is_the_requested_name(self) -> None: @@ -2227,6 +2236,111 @@ async def test_default_anthropic_constructor_receives_parameters(self) -> None: "model": "claude-sonnet-4-5", } + @pytest.mark.asyncio + async def test_bedrock_region_is_prepended_to_the_default_constructor( + self, + ) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("bedrock")]) + ctor = MagicMock(return_value=llm) + cfg = { + **BASE_CONFIG, + "provider": {"name": "Bedrock"}, + "model": { + "name": "anthropic.claude-sonnet-4-5", + "region": "us", + "parameters": {"temperature": 0.2}, + }, + } + with ( + ctx, + patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_agents_handler()(cfg, "q") + assert ctor.call_args.kwargs["model"] == "us.anthropic.claude-sonnet-4-5" + assert cfg["model"]["name"] == "anthropic.claude-sonnet-4-5" + + @pytest.mark.asyncio + async def test_bedrock_region_prefix_is_idempotent(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("bedrock")]) + ctor = MagicMock(return_value=llm) + cfg = { + **BASE_CONFIG, + "provider": {"name": "Bedrock"}, + "model": { + "name": "us.anthropic.claude-sonnet-4-5", + "region": "us", + }, + } + with ( + ctx, + patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_agents_handler()(cfg, "q") + assert ctor.call_args.kwargs["model"] == "us.anthropic.claude-sonnet-4-5" + + @pytest.mark.asyncio + async def test_bedrock_without_region_keeps_the_model_name(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("bedrock")]) + ctor = MagicMock(return_value=llm) + cfg = { + **BASE_CONFIG, + "provider": {"name": "Bedrock"}, + "model": {"name": "anthropic.claude-sonnet-4-5"}, + } + with ( + ctx, + patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_agents_handler()(cfg, "q") + assert ctor.call_args.kwargs["model"] == "anthropic.claude-sonnet-4-5" + + @pytest.mark.asyncio + async def test_non_bedrock_ignores_model_region(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("openai")]) + ctor = MagicMock(return_value=llm) + cfg = { + **BASE_CONFIG, + "provider": {"name": "OpenAI"}, + "model": {"name": "gpt-4o", "region": "us"}, + } + with ( + ctx, + patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_agents_handler()(cfg, "q") + assert ctor.call_args.kwargs["model"] == "gpt-4o" + + @pytest.mark.asyncio + async def test_factory_receives_prefixed_bedrock_name_without_mutating_config( + self, + ) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("from-factory")]) + seen: list[Any] = [] + + def factory(config: Any) -> Any: + seen.append(config) + return llm + + cfg = { + **BASE_CONFIG, + "provider": {"name": "Bedrock"}, + "model": { + "name": "anthropic.claude-sonnet-4-5", + "region": "us", + "parameters": {"temperature": 0.2}, + }, + } + with ctx: + await create_langchain_agents_handler(factory)(cfg, "q") + assert seen[0]["model"]["name"] == "us.anthropic.claude-sonnet-4-5" + assert cfg["model"]["name"] == "anthropic.claude-sonnet-4-5" + assert seen[0] is not cfg + @pytest.mark.asyncio async def test_factory_is_resolved_on_the_streaming_path(self) -> None: ctx, _rec = _recording() diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index 54a466f4..cde32dbc 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -203,13 +203,35 @@ def _is_coroutine(fn: Any) -> bool: _MAX_STEPS = 10 +def _resolved_model_name(config: AiConfigRep, fallback_name: str = "") -> str: + """Bedrock ``model.region`` is an inference-profile prefix, prepended once.""" + model = config.get("model") or {} + name = str(model.get("name") or fallback_name) + provider = str((config.get("provider") or {}).get("name") or "").lower() + if provider != "bedrock": + return name + prefix = str(model.get("region") or "") + if not prefix or name.startswith(f"{prefix}."): + return name + return f"{prefix}.{name}" + + +def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: + """Shallow copy with a resolved Bedrock model name. Does not mutate *config*.""" + resolved = _resolved_model_name(config) + model = dict(config.get("model") or {}) + if model.get("name") == resolved: + return config + return {**config, "model": {**model, "name": resolved}} + + def _model_constructor_kwargs( config: AiConfigRep, fallback_name: str ) -> dict[str, Any]: raw = (config.get("model") or {}).get("parameters") parameters = dict(raw) if isinstance(raw, dict) else {} # Name from the config always wins over a colliding ``model`` key in the parameter bag. - parameters["model"] = (config.get("model") or {}).get("name") or fallback_name + parameters["model"] = _resolved_model_name(config, fallback_name) return parameters @@ -236,10 +258,11 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: async def _resolve_base_model(config: AiConfigRep, llm: Any, importlib: Any) -> Any: + invocation = _config_for_model_call(config) if llm is None: - return _make_default_chat_model(config, importlib) + return _make_default_chat_model(invocation, importlib) if _is_model_factory(llm): - model = llm(config) + model = llm(invocation) if asyncio.iscoroutine(model): return await model return model diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/spans.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/spans.py index 27104094..1bbdc09b 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/spans.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/spans.py @@ -11,8 +11,8 @@ Two provider keys, two different values, on purpose. ``gen_ai.system`` is the literal string ``langchain`` on every span this package opens, because that is what the handler shipped before the span hierarchy landed. ``gen_ai.provider.name`` names *who served the model*, and semconv's -enum has no ``langchain`` member, so it follows :func:`serving_provider` instead: whichever chat -model class the handler actually instantiates. See TELEMETRY-CONTRACT.md section 9. +enum has no ``langchain`` member, so it follows :func:`serving_provider` instead: the configured +provider name, lower-cased. See TELEMETRY-CONTRACT.md section 9. """ from __future__ import annotations @@ -50,17 +50,14 @@ def model_name(config: AiConfigRep) -> str: def serving_provider(config: AiConfigRep) -> str: - """The provider that actually serves the model. + """The configured provider, lower-cased, for ``gen_ai.provider.name``. ``gen_ai.provider.name`` names who served the request, and its semconv enum has no - ``langchain`` member, because LangChain is the framework, not the provider. This mirrors the - choice the handler's model-resolution logic makes: ``ChatAnthropic`` for a configured provider - of ``"anthropic"``, ``ChatOpenAI`` for everything else, including Bedrock, Azure, Cohere, a - typo, or an unset value. Not a passthrough of the configured name. See TELEMETRY-CONTRACT.md - section 9. + ``langchain`` member, because LangChain is the framework, not the provider. Empty or + missing names fall back to ``openai``. ``gen_ai.system`` stays the literal ``langchain``. + See TELEMETRY-CONTRACT.md section 9. """ - provider = str((config.get("provider") or {}).get("name") or "").lower() - return "anthropic" if provider == "anthropic" else "openai" + return str((config.get("provider") or {}).get("name") or "openai").lower() # ─── Span starts ───────────────────────────────────────────────────────────── diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index dc0f7ccb..7fc948c9 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -597,20 +597,26 @@ async def test_gen_ai_provider_name_is_anthropic_only_for_anthropic(self) -> Non assert rec.root.attributes["gen_ai.provider.name"] == "anthropic" @pytest.mark.parametrize( - "provider_name", ["OpenAI", "Bedrock", "Azure", "Cohere", "Typo", ""] + ("provider_name", "expected"), + [ + ("OpenAI", "openai"), + ("Bedrock", "bedrock"), + ("Azure", "azure"), + ("Cohere", "cohere"), + ("Typo", "typo"), + ("", "openai"), + ], ) - async def test_gen_ai_provider_name_is_openai_for_everything_else( - self, provider_name: str + async def test_gen_ai_provider_name_is_the_configured_name( + self, provider_name: str, expected: str ) -> None: - # Not a passthrough. Bedrock, Azure, Cohere, a typo and an unset value all report `openai`, - # mirroring the chat model class the handler actually instantiates. Section 9. ctx, rec = _recording() from launchdarkly_ai_langchain_messages import create_langchain_messages_handler cfg = {**CONFIG, "provider": {"name": provider_name}} with ctx: await create_langchain_messages_handler(llm=_make_llm())(cfg, "q", {}, {}) - assert rec.root.attributes["gen_ai.provider.name"] == "openai" + assert rec.root.attributes["gen_ai.provider.name"] == expected async def test_writes_the_requested_model(self) -> None: ctx, rec = _recording() @@ -2394,6 +2400,121 @@ async def test_default_anthropic_constructor_receives_parameters(self) -> None: "model": "claude-sonnet-4-5", } + @pytest.mark.asyncio + async def test_bedrock_region_is_prepended_to_the_default_constructor( + self, + ) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("bedrock") + ctor = MagicMock(return_value=llm) + cfg = { + **CONFIG, + "provider": {"name": "Bedrock"}, + "model": { + "name": "anthropic.claude-sonnet-4-5", + "region": "us", + "parameters": {"temperature": 0.2}, + }, + } + with ( + ctx, + patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert ctor.call_args.kwargs["model"] == "us.anthropic.claude-sonnet-4-5" + assert cfg["model"]["name"] == "anthropic.claude-sonnet-4-5" + + @pytest.mark.asyncio + async def test_bedrock_region_prefix_is_idempotent(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("bedrock") + ctor = MagicMock(return_value=llm) + cfg = { + **CONFIG, + "provider": {"name": "Bedrock"}, + "model": { + "name": "us.anthropic.claude-sonnet-4-5", + "region": "us", + }, + } + with ( + ctx, + patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert ctor.call_args.kwargs["model"] == "us.anthropic.claude-sonnet-4-5" + + @pytest.mark.asyncio + async def test_bedrock_without_region_keeps_the_model_name(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("bedrock") + ctor = MagicMock(return_value=llm) + cfg = { + **CONFIG, + "provider": {"name": "Bedrock"}, + "model": {"name": "anthropic.claude-sonnet-4-5"}, + } + with ( + ctx, + patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert ctor.call_args.kwargs["model"] == "anthropic.claude-sonnet-4-5" + + @pytest.mark.asyncio + async def test_non_bedrock_ignores_model_region(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("openai") + ctor = MagicMock(return_value=llm) + cfg = { + **CONFIG, + "provider": {"name": "OpenAI"}, + "model": {"name": "gpt-4o", "region": "us"}, + } + with ( + ctx, + patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert ctor.call_args.kwargs["model"] == "gpt-4o" + + @pytest.mark.asyncio + async def test_factory_receives_prefixed_bedrock_name_without_mutating_config( + self, + ) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("from-factory") + seen: list[Any] = [] + + def factory(config: Any) -> Any: + seen.append(config) + return llm + + cfg = { + **CONFIG, + "provider": {"name": "Bedrock"}, + "model": { + "name": "anthropic.claude-sonnet-4-5", + "region": "us", + "parameters": {"temperature": 0.2}, + }, + } + with ctx: + await create_langchain_messages_handler(llm=factory)(cfg, "q", {}, {}) + assert seen[0]["model"]["name"] == "us.anthropic.claude-sonnet-4-5" + assert cfg["model"]["name"] == "anthropic.claude-sonnet-4-5" + assert seen[0] is not cfg + @pytest.mark.asyncio async def test_factory_is_resolved_on_the_streaming_path(self) -> None: ctx, _rec = _recording() diff --git a/tests/test_cross_handler_parity.py b/tests/test_cross_handler_parity.py index 8ab0db56..3ab7513a 100644 --- a/tests/test_cross_handler_parity.py +++ b/tests/test_cross_handler_parity.py @@ -288,19 +288,17 @@ def test_the_langchain_handlers_keep_the_framework_on_the_legacy_key( assert attrs["gen_ai.system"] == "langchain" assert attrs["gen_ai.provider.name"] == "anthropic" - def test_the_langchain_provider_name_is_binary_not_a_passthrough( + def test_the_langchain_provider_name_is_the_configured_name( self, handler_spans: Any ) -> None: - # Anything that is not Anthropic is served by the OpenAI client, so the attribute follows the - # client actually instantiated rather than whatever the config happens to name. package, module, _ = handler_spans if not package.startswith("langchain-"): - pytest.skip("only the LangChain handlers make this choice") + pytest.skip("only the LangChain handlers pass through provider.name") for configured, expected in ( ("Anthropic", "anthropic"), ("OpenAI", "openai"), - ("Bedrock", "openai"), - ("Azure", "openai"), + ("Bedrock", "bedrock"), + ("Azure", "azure"), ("", "openai"), ): config = {**CONFIG, "provider": {"name": configured}} From 6ca251d1a069b7bfd0ed3e92e875a26d2474f488 Mon Sep 17 00:00:00 2001 From: Andrew Klatzke Date: Fri, 18 Sep 2026 11:36:24 -0800 Subject: [PATCH 2/4] fix(AIC-3382): construct Bedrock LangChain models by default Route Bedrock configs through ChatBedrockConverse so callers no longer need to provide a custom model factory. Co-authored-by: Cursor --- .../handler.py | 9 +++++ .../langchain-agents/tests/test_handler.py | 36 ++++++++++++++++--- .../handler.py | 9 +++++ .../langchain-messages/tests/test_handler.py | 35 +++++++++++++++--- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index cd278dae..b28f0c4c 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -198,6 +198,15 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: return lc_anthropic.ChatAnthropic( **_model_constructor_kwargs(config, "claude-3-5-sonnet-20241022") ) + if provider == "bedrock": + try: + lc_aws = importlib.import_module("langchain_aws") + except ImportError as exc: + raise ImportError( + "Using Bedrock models requires langchain-aws. " + "Install it with: pip install langchain-aws" + ) from exc + return lc_aws.ChatBedrockConverse(**_model_constructor_kwargs(config, "")) lc_openai = importlib.import_module("langchain_openai") return lc_openai.ChatOpenAI(**_model_constructor_kwargs(config, "gpt-4o")) diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index 1d268930..dbf1671b 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -2254,10 +2254,16 @@ async def test_bedrock_region_is_prepended_to_the_default_constructor( } with ( ctx, - patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + patch.dict( + "sys.modules", + {"langchain_aws": MagicMock(ChatBedrockConverse=ctor)}, + ), ): await create_langchain_agents_handler()(cfg, "q") - assert ctor.call_args.kwargs["model"] == "us.anthropic.claude-sonnet-4-5" + assert ctor.call_args.kwargs == { + "temperature": 0.2, + "model": "us.anthropic.claude-sonnet-4-5", + } assert cfg["model"]["name"] == "anthropic.claude-sonnet-4-5" @pytest.mark.asyncio @@ -2275,7 +2281,10 @@ async def test_bedrock_region_prefix_is_idempotent(self) -> None: } with ( ctx, - patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + patch.dict( + "sys.modules", + {"langchain_aws": MagicMock(ChatBedrockConverse=ctor)}, + ), ): await create_langchain_agents_handler()(cfg, "q") assert ctor.call_args.kwargs["model"] == "us.anthropic.claude-sonnet-4-5" @@ -2292,11 +2301,30 @@ async def test_bedrock_without_region_keeps_the_model_name(self) -> None: } with ( ctx, - patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + patch.dict( + "sys.modules", + {"langchain_aws": MagicMock(ChatBedrockConverse=ctor)}, + ), ): await create_langchain_agents_handler()(cfg, "q") assert ctor.call_args.kwargs["model"] == "anthropic.claude-sonnet-4-5" + def test_bedrock_without_langchain_aws_has_a_clear_error(self) -> None: + cfg = { + **BASE_CONFIG, + "provider": {"name": "Bedrock"}, + "model": {"name": "anthropic.claude-sonnet-4-5"}, + } + with patch( + "importlib.import_module", + side_effect=ModuleNotFoundError("No module named 'langchain_aws'"), + ): + with pytest.raises( + ImportError, + match=r"pip install langchain-aws", + ): + handler_mod._make_default_chat_model(cfg) + @pytest.mark.asyncio async def test_non_bedrock_ignores_model_region(self) -> None: ctx, _rec = _recording() diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index cde32dbc..e1cff463 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -253,6 +253,15 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: return lc_anthropic.ChatAnthropic( **_model_constructor_kwargs(config, "claude-3-5-sonnet-20241022") ) + if provider == "bedrock": + try: + lc_aws = importlib.import_module("langchain_aws") + except ImportError as exc: + raise ImportError( + "Using Bedrock models requires langchain-aws. " + "Install it with: pip install langchain-aws" + ) from exc + return lc_aws.ChatBedrockConverse(**_model_constructor_kwargs(config, "")) lc_openai = importlib.import_module("langchain_openai") return lc_openai.ChatOpenAI(**_model_constructor_kwargs(config, "gpt-4o")) diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index 7fc948c9..3edce803 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -2420,10 +2420,16 @@ async def test_bedrock_region_is_prepended_to_the_default_constructor( } with ( ctx, - patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + patch.dict( + sys.modules, + {"langchain_aws": MagicMock(ChatBedrockConverse=ctor)}, + ), ): await create_langchain_messages_handler()(cfg, "q", {}, {}) - assert ctor.call_args.kwargs["model"] == "us.anthropic.claude-sonnet-4-5" + assert ctor.call_args.kwargs == { + "temperature": 0.2, + "model": "us.anthropic.claude-sonnet-4-5", + } assert cfg["model"]["name"] == "anthropic.claude-sonnet-4-5" @pytest.mark.asyncio @@ -2443,7 +2449,10 @@ async def test_bedrock_region_prefix_is_idempotent(self) -> None: } with ( ctx, - patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + patch.dict( + sys.modules, + {"langchain_aws": MagicMock(ChatBedrockConverse=ctor)}, + ), ): await create_langchain_messages_handler()(cfg, "q", {}, {}) assert ctor.call_args.kwargs["model"] == "us.anthropic.claude-sonnet-4-5" @@ -2462,11 +2471,29 @@ async def test_bedrock_without_region_keeps_the_model_name(self) -> None: } with ( ctx, - patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + patch.dict( + sys.modules, + {"langchain_aws": MagicMock(ChatBedrockConverse=ctor)}, + ), ): await create_langchain_messages_handler()(cfg, "q", {}, {}) assert ctor.call_args.kwargs["model"] == "anthropic.claude-sonnet-4-5" + def test_bedrock_without_langchain_aws_has_a_clear_error(self) -> None: + import launchdarkly_ai_langchain_messages.handler as handler_mod + + cfg = { + **CONFIG, + "provider": {"name": "Bedrock"}, + "model": {"name": "anthropic.claude-sonnet-4-5"}, + } + imports = MagicMock() + imports.import_module.side_effect = ModuleNotFoundError( + "No module named 'langchain_aws'" + ) + with pytest.raises(ImportError, match=r"pip install langchain-aws"): + handler_mod._make_default_chat_model(cfg, imports) + @pytest.mark.asyncio async def test_non_bedrock_ignores_model_region(self) -> None: ctx, _rec = _recording() From 2152f67c6efdf5ad76a564be7d6eb5444cf13cfa Mon Sep 17 00:00:00 2001 From: Andrew Klatzke Date: Fri, 18 Sep 2026 13:05:43 -0800 Subject: [PATCH 3/4] fix(AIC-3382): strip duplicate Bedrock tools Prevent payload-owned tool definitions from leaking into ChatBedrockConverse constructor arguments while preserving the evaluated config. Co-authored-by: Cursor --- .../handler.py | 3 ++ .../langchain-agents/tests/test_handler.py | 30 +++++++++++++++---- .../handler.py | 3 ++ .../langchain-messages/tests/test_handler.py | 25 +++++++++++++--- 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index b28f0c4c..bc72beb3 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -174,6 +174,9 @@ def _model_constructor_kwargs( ) -> dict[str, Any]: raw = (config.get("model") or {}).get("parameters") parameters = dict(raw) if isinstance(raw, dict) else {} + provider = str((config.get("provider") or {}).get("name") or "").lower() + if provider == "bedrock": + parameters.pop("tools", None) parameters["model"] = _resolved_model_name(config, fallback_name) return parameters diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index dbf1671b..1861a1ed 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -2169,12 +2169,20 @@ def factory(config: Any) -> Any: **BASE_CONFIG, "model": { "name": "gpt-4o", - "parameters": {"temperature": 0.2, "max_tokens": 512}, + "parameters": { + "temperature": 0.2, + "max_tokens": 512, + "tools": [{"name": "openai-tool"}], + }, }, } with ctx: result = await create_langchain_agents_handler(factory)(cfg, "q") - assert seen[0]["model"]["parameters"] == {"temperature": 0.2, "max_tokens": 512} + assert seen[0]["model"]["parameters"] == { + "temperature": 0.2, + "max_tokens": 512, + "tools": [{"name": "openai-tool"}], + } assert result["output"] == "from-factory" @pytest.mark.asyncio @@ -2200,7 +2208,11 @@ async def test_default_openai_constructor_receives_parameters(self) -> None: **BASE_CONFIG, "model": { "name": "gpt-4o", - "parameters": {"temperature": 0.2, "max_tokens": 512}, + "parameters": { + "temperature": 0.2, + "max_tokens": 512, + "tools": [{"name": "openai-tool"}], + }, }, } with ( @@ -2211,6 +2223,7 @@ async def test_default_openai_constructor_receives_parameters(self) -> None: assert ctor.call_args.kwargs == { "temperature": 0.2, "max_tokens": 512, + "tools": [{"name": "openai-tool"}], "model": "gpt-4o", } @@ -2246,10 +2259,14 @@ async def test_bedrock_region_is_prepended_to_the_default_constructor( cfg = { **BASE_CONFIG, "provider": {"name": "Bedrock"}, + "tools": TOOL_CONFIG["tools"], "model": { "name": "anthropic.claude-sonnet-4-5", "region": "us", - "parameters": {"temperature": 0.2}, + "parameters": { + "temperature": 0.2, + "tools": [{"name": "duplicated-search"}], + }, }, } with ( @@ -2259,11 +2276,14 @@ async def test_bedrock_region_is_prepended_to_the_default_constructor( {"langchain_aws": MagicMock(ChatBedrockConverse=ctor)}, ), ): - await create_langchain_agents_handler()(cfg, "q") + await create_langchain_agents_handler()( + cfg, "q", {"search": AsyncMock(return_value="result")} + ) assert ctor.call_args.kwargs == { "temperature": 0.2, "model": "us.anthropic.claude-sonnet-4-5", } + assert cfg["model"]["parameters"]["tools"] == [{"name": "duplicated-search"}] assert cfg["model"]["name"] == "anthropic.claude-sonnet-4-5" @pytest.mark.asyncio diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index e1cff463..b30e8b80 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -230,6 +230,9 @@ def _model_constructor_kwargs( ) -> dict[str, Any]: raw = (config.get("model") or {}).get("parameters") parameters = dict(raw) if isinstance(raw, dict) else {} + provider = str((config.get("provider") or {}).get("name") or "").lower() + if provider == "bedrock": + parameters.pop("tools", None) # Name from the config always wins over a colliding ``model`` key in the parameter bag. parameters["model"] = _resolved_model_name(config, fallback_name) return parameters diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index 3edce803..d00e43fe 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -2317,14 +2317,22 @@ def factory(config: Any) -> Any: **CONFIG, "model": { "name": "gpt-4o", - "parameters": {"temperature": 0.2, "max_tokens": 512}, + "parameters": { + "temperature": 0.2, + "max_tokens": 512, + "tools": [{"name": "openai-tool"}], + }, }, } with ctx: result = await create_langchain_messages_handler(llm=factory)( cfg, "q", {}, {} ) - assert seen[0]["model"]["parameters"] == {"temperature": 0.2, "max_tokens": 512} + assert seen[0]["model"]["parameters"] == { + "temperature": 0.2, + "max_tokens": 512, + "tools": [{"name": "openai-tool"}], + } assert result["output"] == "from-factory" llm.ainvoke.assert_awaited() @@ -2358,7 +2366,11 @@ async def test_default_openai_constructor_receives_parameters(self) -> None: **CONFIG, "model": { "name": "gpt-4o", - "parameters": {"temperature": 0.2, "max_tokens": 512}, + "parameters": { + "temperature": 0.2, + "max_tokens": 512, + "tools": [{"name": "openai-tool"}], + }, }, } with ( @@ -2369,6 +2381,7 @@ async def test_default_openai_constructor_receives_parameters(self) -> None: assert ctor.call_args.kwargs == { "temperature": 0.2, "max_tokens": 512, + "tools": [{"name": "openai-tool"}], "model": "gpt-4o", } @@ -2415,7 +2428,10 @@ async def test_bedrock_region_is_prepended_to_the_default_constructor( "model": { "name": "anthropic.claude-sonnet-4-5", "region": "us", - "parameters": {"temperature": 0.2}, + "parameters": { + "temperature": 0.2, + "tools": [{"name": "duplicated-search"}], + }, }, } with ( @@ -2430,6 +2446,7 @@ async def test_bedrock_region_is_prepended_to_the_default_constructor( "temperature": 0.2, "model": "us.anthropic.claude-sonnet-4-5", } + assert cfg["model"]["parameters"]["tools"] == [{"name": "duplicated-search"}] assert cfg["model"]["name"] == "anthropic.claude-sonnet-4-5" @pytest.mark.asyncio From 221c23fed2e41f81ad3072183d18f1ecf2c5914d Mon Sep 17 00:00:00 2001 From: Andrew Klatzke Date: Fri, 18 Sep 2026 13:12:47 -0800 Subject: [PATCH 4/4] fix(AIC-3382): print the done-event response when no chunks arrive Keep the streaming example aligned with TypeScript when a provider emits only a final event. Co-authored-by: Cursor --- examples/streaming.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/streaming.py b/examples/streaming.py index c6aaab33..c4b79459 100644 --- a/examples/streaming.py +++ b/examples/streaming.py @@ -31,13 +31,17 @@ async def run(key: str, user_input: str) -> None: registry=global_registry, ).stream(user_input, new_context()) + wrote_chunk = False async for event in stream: if event["type"] == "chunk": sys.stdout.write(event.get("text", "")) sys.stdout.flush() + wrote_chunk = True else: # Final event — full response + normalised usage - sys.stdout.write("\n") + if not wrote_chunk: + sys.stdout.write(str(event.get("response") or "")) + sys.stdout.write("\n\n") print("Usage:", json_pretty(event.get("usage"))) if event.get("judgeResults"): print("Judge results:", json_pretty(event["judgeResults"]))