From 9ad50a5871537a455fc650ff2b06a14f45dfce85 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 11:02:59 -0400 Subject: [PATCH 01/26] fix(client): forward model.parameters to every provider handler model.parameters is a free-form dict the LaunchDarkly UI writes for provider tuning values, including max_turns for agent turn caps. Every Python handler except claude-messages ignored it entirely, and claude-messages read only max_tokens. In particular there was no way to cap an agent's turns: claude-agents never set ClaudeAgentOptions.max_turns and openai-agents never passed max_turns to Runner.run. Adds a shared launchdarkly_ai_server.model_parameters(config) helper that returns model.parameters as a fresh dict (or {} when absent/not a mapping), and never reads model.custom. Applies it at every provider call site across all six handler packages, including streaming and native-graph paths. Keys are forwarded as-is (snake_case, matching both the UI and every Python provider SDK here); no allowlisting and no case conversion. Handler-owned keys (model, messages/input, system/instructions, tools, etc., decided per call site) are always removed from the forwarded dict before merging with the handler's own kwargs, so a config value can never override what the handler itself sets. claude-messages keeps its max_tokens-defaults-to-1024 behaviour exactly. LangChain's handlers already forwarded model.parameters through a local _model_constructor_kwargs helper; swapped that to use the new shared helper for consistency. Verified: uv run pytest (1332 passed, 11 skipped), uv run mypy packages/*/src, uv run ruff check ., uv run ruff format --check . Co-Authored-By: Claude Opus 5.5 (1M context) --- .../launchdarkly_ai_claude_agents/handler.py | 12 ++ .../native_graph.py | 13 ++ packages/claude-agents/tests/test_handler.py | 49 ++++++ .../handler.py | 17 ++- .../claude-messages/tests/test_handler.py | 143 ++++++++++++++++++ .../src/launchdarkly_ai_server/__init__.py | 2 + .../src/launchdarkly_ai_server/utils.py | 20 +++ packages/client/tests/test_utils.py | 46 ++++++ .../handler.py | 4 +- .../native_graph.py | 4 +- .../handler.py | 4 +- .../langchain-messages/tests/test_handler.py | 57 +++++++ .../launchdarkly_ai_openai_agents/handler.py | 17 ++- .../native_graph.py | 21 ++- packages/openai-agents/tests/test_handler.py | 97 ++++++++++++ .../handler.py | 22 +++ .../openai-messages/tests/test_handler.py | 67 ++++++++ 17 files changed, 580 insertions(+), 15 deletions(-) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 32ee9fd6..dc196e51 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -41,6 +41,7 @@ create_handler, end_span_once, end_unfinished_spans, + model_parameters, parse_template, set_conversation_id_if_absent, set_input_content_attributes, @@ -479,7 +480,18 @@ def _build_query_options( **extra: Any, ) -> ClaudeAgentOptions: all_allowed = [*mcp_allowed_tools, *native_tool_names] + params = model_parameters(config) + for _owned_key in ( + "model", + "allowed_tools", + "mcp_servers", + "hooks", + "tools", + "system_prompt", + ): + params.pop(_owned_key, None) kwargs: dict[str, Any] = { + **params, "model": config["model"]["name"], "allowed_tools": all_allowed if all_allowed else [], "mcp_servers": {TOOL_MCP_NAME: tool_mcp} if tool_mcp else {}, diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index 8fa8ce33..3876adf5 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -18,6 +18,7 @@ NativeTool, get_client, make_track_data, + model_parameters, to_ld_context, ) @@ -149,7 +150,19 @@ async def _run_query( hooks = _build_hooks(native_tool_map) + params = model_parameters(node.config) + for _owned_key in ( + "model", + "tools", + "allowed_tools", + "mcp_servers", + "hooks", + "system_prompt", + ): + params.pop(_owned_key, None) + options = ClaudeAgentOptions( + **params, # Explicitly set the available built-in tools (empty list disables all). # When no native tools are needed, disable built-in tools so Claude # cannot call WebSearch/Bash/etc. and get stuck waiting for permission diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index 5651391e..760970db 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -1442,6 +1442,55 @@ async def test_ld_span_attributes_land_on_root_only( assert [e.name for e in root().events] == ["feature_flag"] +class TestModelParametersForwarding: + async def _run_and_capture_options( + self, config: dict[str, Any], monkeypatch: pytest.MonkeyPatch + ) -> Any: + captured: dict[str, Any] = {} + + async def _query(**kwargs: Any) -> AsyncIterator[Any]: + captured["options"] = kwargs["options"] + yield assistant_message() + yield result_message() + + monkeypatch.setattr(handler_mod, "query", _query) + await create_claude_agents_handler()(config, "q") + return captured["options"] + + async def test_max_turns_from_config_reaches_options( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = { + **BASE_CONFIG, + "model": {**BASE_CONFIG["model"], "parameters": {"max_turns": 3}}, + } + options = await self._run_and_capture_options(config, monkeypatch) + assert options.max_turns == 3 + + async def test_config_cannot_override_model_or_system_prompt( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = { + **BASE_CONFIG, + "model": { + **BASE_CONFIG["model"], + "parameters": { + "model": "not-the-real-model", + "system_prompt": "not-the-real-prompt", + }, + }, + } + options = await self._run_and_capture_options(config, monkeypatch) + assert options.model == BASE_CONFIG["model"]["name"] + assert options.system_prompt != "not-the-real-prompt" + + async def test_unset_when_no_parameters( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + options = await self._run_and_capture_options(BASE_CONFIG, monkeypatch) + assert options.max_turns is None + + class TestFinishReasonMapping: async def test_tool_use_maps_to_tool_calls( self, monkeypatch: pytest.MonkeyPatch diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index 0de014a3..d68b6cc3 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -18,6 +18,7 @@ end_span_once, end_unfinished_spans, is_content_blocks, + model_parameters, parse_template, set_input_content_attributes, set_output_content_attributes, @@ -201,9 +202,10 @@ async def _run_tool_loop( # difference predates this span work and changes what the model is offered, not what the span # reports, so it stays as it is: the catalog recorded below is the catalog actually sent. tools = _build_tools(config.get("tools") or {}) - max_tokens = (config.get("model", {}).get("parameters") or {}).get( - "max_tokens", 1024 - ) + extra_params = model_parameters(config) + max_tokens = extra_params.pop("max_tokens", 1024) + for _owned_key in ("model", "messages", "system", "tools"): + extra_params.pop(_owned_key, None) conversation = list(messages) output = "" steps = 0 @@ -222,6 +224,7 @@ async def _run_tool_loop( open_model_span = model_span kwargs: dict[str, Any] = { + **extra_params, "model": config["model"]["name"], "max_tokens": max_tokens, "messages": conversation, @@ -494,9 +497,10 @@ async def _stream_gen( tools = _build_tools(config.get("tools") or {}) tool_definitions = to_tool_definitions(tools) - max_tokens = (config.get("model", {}).get("parameters") or {}).get( - "max_tokens", 1024 - ) + extra_params = model_parameters(config) + max_tokens = extra_params.pop("max_tokens", 1024) + for _owned_key in ("model", "messages", "system", "tools"): + extra_params.pop(_owned_key, None) conversation = list(messages) full_output = "" steps = 0 @@ -528,6 +532,7 @@ async def _stream_gen( open_model_span = model_span kwargs: dict[str, Any] = { + **extra_params, "model": config["model"]["name"], "max_tokens": max_tokens, "messages": conversation, diff --git a/packages/claude-messages/tests/test_handler.py b/packages/claude-messages/tests/test_handler.py index f07336fe..d8d2753a 100644 --- a/packages/claude-messages/tests/test_handler.py +++ b/packages/claude-messages/tests/test_handler.py @@ -6,12 +6,14 @@ from __future__ import annotations +import json from collections.abc import AsyncGenerator, AsyncIterator from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any, ClassVar from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest # --------------------------------------------------------------------------- @@ -364,6 +366,147 @@ async def test_custom_parameters_passthrough( # --------------------------------------------------------------------------- +class TestModelParametersForwarding: + async def test_snake_case_param_reaches_provider( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"top_p": 0.5}}, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["top_p"] == 0.5 + + async def test_max_tokens_default_preserved_without_parameters( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + h = create_claude_messages_handler() + await h(CONFIG, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["max_tokens"] == 1024 + + async def test_config_max_tokens_wins_over_default( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"max_tokens": 42}}, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["max_tokens"] == 42 + + async def test_config_cannot_override_model_or_messages( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": { + "model": "not-the-real-model", + "messages": "not-the-real-messages", + "system": "not-the-real-system", + }, + }, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["model"] == CONFIG["model"]["name"] + assert call_kwargs["messages"] != "not-the-real-messages" + assert ( + "system" not in call_kwargs + or call_kwargs["system"] != "not-the-real-system" + ) + + async def test_call_unchanged_when_no_parameters_set( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + h = create_claude_messages_handler() + await h(CONFIG, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert set(call_kwargs.keys()) == {"model", "max_tokens", "messages", "system"} + + async def test_streaming_forwards_snake_case_param( + self, mock_anthropic: MagicMock + ) -> None: + import launchdarkly_ai_claude_messages.spans as spans_mod + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + ctx, _ = _make_stream_context(["hi"]) + mock_anthropic.messages.stream = MagicMock(return_value=ctx) + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"top_p": 0.3}}, + } + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_claude_messages_handler() + gen = await h.stream(config, "q", {}, {}) + async for _event in gen: + pass + call_kwargs = mock_anthropic.messages.stream.call_args.kwargs + assert call_kwargs["top_p"] == 0.3 + + async def test_top_p_and_max_tokens_reach_the_wire(self) -> None: + """Intercepts the real outgoing HTTP request with an httpx MockTransport, rather than + asserting only on a mock of our own call, so this proves the values actually leave the + process on the wire the real ``anthropic`` client builds. + """ + import anthropic + + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + captured: dict[str, Any] = {} + + def _handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-3-sonnet-20240229", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 3, "output_tokens": 2}, + }, + ) + + transport = httpx.MockTransport(_handler) + real_client = anthropic.AsyncAnthropic( + api_key="test-key", http_client=httpx.AsyncClient(transport=transport) + ) + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": {"top_p": 0.4, "max_tokens": 256}, + }, + } + with patch("anthropic.AsyncAnthropic", return_value=real_client): + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + + assert captured["body"]["top_p"] == 0.4 + assert captured["body"]["max_tokens"] == 256 + + class TestToolExecutionLoop: async def test_single_tool_call_then_done(self, mock_anthropic: MagicMock) -> None: from launchdarkly_ai_claude_messages import create_claude_messages_handler diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index 869d0ef0..b7f50efb 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -109,6 +109,7 @@ end_unfinished_spans, lang_chain_span_usage, make_track_data, + model_parameters, model_stamps_from_meta, normalize_mode, number_or_zero, @@ -197,6 +198,7 @@ # utils "create_handler", "make_track_data", + "model_parameters", "model_stamps_from_meta", "omit_model_stamps", "normalize_mode", diff --git a/packages/client/src/launchdarkly_ai_server/utils.py b/packages/client/src/launchdarkly_ai_server/utils.py index 2958de6e..f3cd71d3 100644 --- a/packages/client/src/launchdarkly_ai_server/utils.py +++ b/packages/client/src/launchdarkly_ai_server/utils.py @@ -36,6 +36,26 @@ def create_handler( ) +def model_parameters(config: AiConfigRep) -> dict[str, Any]: + """ + Returns ``config['model']['parameters']`` as a fresh dict, or ``{}`` when it + is absent or not a usable mapping. + + This is the single place handler packages read provider tuning values from. + Values are forwarded to the provider as-is, keyed by whatever name the + LaunchDarkly UI wrote (already snake_case for every Python provider SDK + here), so no case conversion happens on the way through. Callers must + still remove any key the call site sets itself before merging the result + into a provider call, so a config value never overrides a handler-owned + argument. + + Never reads ``model.custom`` — that field is not forwarded to providers. + """ + model = config.get("model") if isinstance(config, dict) else None + parameters = model.get("parameters") if isinstance(model, dict) else None + return dict(parameters) if isinstance(parameters, dict) else {} + + def collapse_messages_to_instructions(config: AiConfigRep) -> AiConfigRep: """ When only an agent handler is available for a messages-mode config, collapse diff --git a/packages/client/tests/test_utils.py b/packages/client/tests/test_utils.py index 88f05298..54b0e97d 100644 --- a/packages/client/tests/test_utils.py +++ b/packages/client/tests/test_utils.py @@ -11,6 +11,7 @@ from launchdarkly_ai_server import ( create_handler, make_track_data, + model_parameters, model_stamps_from_meta, normalize_mode, omit_model_stamps, @@ -348,3 +349,48 @@ def test_does_not_mutate_input(self) -> None: td = {"runId": "r", "modelKey": "m"} omit_model_stamps(td) assert td == {"runId": "r", "modelKey": "m"} + + +class TestModelParameters: + def test_returns_the_parameters_dict(self) -> None: + config = {"model": {"name": "gpt-4o", "parameters": {"temperature": 0.2}}} + assert model_parameters(config) == {"temperature": 0.2} + + def test_returns_a_fresh_dict_not_the_original(self) -> None: + original = {"temperature": 0.2} + config = {"model": {"name": "gpt-4o", "parameters": original}} + result = model_parameters(config) + result["temperature"] = 99 + assert original["temperature"] == 0.2 + + @pytest.mark.parametrize( + "model", + [None, {}, {"name": "gpt-4o"}, {"name": "gpt-4o", "parameters": None}], + ) + def test_returns_empty_dict_when_parameters_absent(self, model: Any) -> None: + config: dict[str, Any] = {} + if model is not None: + config["model"] = model + assert model_parameters(config) == {} + + @pytest.mark.parametrize("parameters", ["not-a-dict", 1, ["a", "b"], True]) + def test_returns_empty_dict_when_parameters_not_a_mapping( + self, parameters: Any + ) -> None: + config = {"model": {"name": "gpt-4o", "parameters": parameters}} + assert model_parameters(config) == {} + + def test_ignores_non_dict_config(self) -> None: + assert model_parameters("not-a-config") == {} # type: ignore[arg-type] + + def test_never_reads_custom(self) -> None: + config = { + "model": { + "name": "gpt-4o", + "parameters": {"temperature": 0.2}, + "custom": {"secret": "value"}, + } + } + result = model_parameters(config) + assert "secret" not in result + assert "custom" not in result 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 bc72beb3..4617bbb4 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -25,6 +25,7 @@ lang_chain_content_text, lang_chain_span_messages, lang_chain_span_usage, + model_parameters, parse_template, set_input_content_attributes, set_output_content_attributes, @@ -172,8 +173,7 @@ def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: 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_parameters(config) provider = str((config.get("provider") or {}).get("name") or "").lower() if provider == "bedrock": parameters.pop("tools", None) diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index bf959881..2cb9b715 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -18,6 +18,7 @@ compose_history, get_client, make_track_data, + model_parameters, parse_template, to_ld_context, ) @@ -201,8 +202,7 @@ async def _traverse_node(node: GraphNode) -> None: else: lc_openai = importlib.import_module("langchain_openai") model_cfg = node.config.get("model") or {} - raw = model_cfg.get("parameters") - kwargs = dict(raw) if isinstance(raw, dict) else {} + kwargs = model_parameters(node.config) kwargs["model"] = model_cfg.get("name") or "gpt-4o" chat_model = lc_openai.ChatOpenAI(**kwargs) 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 b30e8b80..ddf686d5 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -25,6 +25,7 @@ lang_chain_finish_reasons, lang_chain_span_messages, lang_chain_span_usage, + model_parameters, number_or_zero, parse_template, set_input_content_attributes, @@ -228,8 +229,7 @@ def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: 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_parameters(config) provider = str((config.get("provider") or {}).get("name") or "").lower() if provider == "bedrock": parameters.pop("tools", None) diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index d00e43fe..1b30bc6d 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -2586,3 +2586,60 @@ def factory(config: Any) -> Any: assert any( e.get("type") == "chunk" and e.get("text") == "streamed" for e in events ) + + +class TestModelParametersReachTheWire: + """Intercepts the real outgoing HTTP request with an httpx MockTransport, rather than + asserting only on a mock of our own call, so this proves ``top_p`` from ``model.parameters`` + actually leaves the process on the wire the real ``ChatOpenAI`` client builds. + """ + + @pytest.mark.asyncio + async def test_top_p_reaches_the_wire(self) -> None: + import httpx + + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + captured: dict[str, Any] = {} + + def _handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + }, + ) + + transport = httpx.MockTransport(_handler) + cfg = { + **CONFIG, + "model": { + "name": "gpt-4o", + "parameters": { + "top_p": 0.5, + "api_key": "test-key", + "http_async_client": httpx.AsyncClient(transport=transport), + }, + }, + } + with ctx: + result = await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert captured["body"]["top_p"] == 0.5 + assert result["output"] == "hi" diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index faeee70c..8ef1d2b4 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -37,6 +37,7 @@ end_span_once, end_unfinished_spans, image_block_to_url, + model_parameters, parse_template, set_input_content_attributes, set_output_content_attributes, @@ -210,9 +211,17 @@ def _build_agent_and_prompt( # change, not a telemetry one, so it is left alone. `_call_impl` still returns the parsed # `final_output` object as-is when `outputFormat` is configured, matching the pre-existing # return-shape contract. + model_settings_params = model_parameters(config) + # `max_turns` is a `Runner.run` option, not a `ModelSettings` field. + model_settings_params.pop("max_turns", None) agent = Agent( name="assistant", model=config.get("model", {}).get("name", "gpt-4o"), + **( + {"model_settings": agents_mod.ModelSettings(**model_settings_params)} + if model_settings_params + else {} + ), **({"instructions": instructions} if instructions else {}), **({"tools": tools} if tools else {}), ) @@ -498,7 +507,9 @@ async def _call_impl( system_instructions=instructions, messages=to_request_span_messages(prompt), ) - result = await Runner.run(agent, prompt, hooks=hooks) + max_turns = model_parameters(config).get("max_turns") + run_kwargs = {"max_turns": max_turns} if max_turns is not None else {} + result = await Runner.run(agent, prompt, hooks=hooks, **run_kwargs) final_output = result.final_output set_output_content_attributes( span, @@ -628,7 +639,9 @@ async def _stream_gen( system_instructions=instructions, messages=to_request_span_messages(prompt), ) - streamed = Runner.run_streamed(agent, prompt, hooks=hooks) + max_turns = model_parameters(config).get("max_turns") + run_kwargs = {"max_turns": max_turns} if max_turns is not None else {} + streamed = Runner.run_streamed(agent, prompt, hooks=hooks, **run_kwargs) full_output = "" async for event in streamed.stream_events(): diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index 88aef9cb..fe56dc2a 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -18,6 +18,7 @@ compose_history, get_client, make_track_data, + model_parameters, parse_template, to_ld_context, ) @@ -171,9 +172,21 @@ async def _visit(node_key: str) -> None: agent_name = _sanitize_name(node.key) agent_name_to_key[agent_name] = node.key + node_model_settings_params = model_parameters(node.config) + # `max_turns` is a `Runner.run` option, not a `ModelSettings` field. + node_model_settings_params.pop("max_turns", None) agent = Agent( name=agent_name, model=node.config.get("model", {}).get("name", "gpt-4o"), + **( + { + "model_settings": agents_mod.ModelSettings( + **node_model_settings_params + ) + } + if node_model_settings_params + else {} + ), **({"instructions": instructions} if instructions else {}), **({"tools": tools} if tools else {}), **({"handoffs": child_handoffs} if child_handoffs else {}), @@ -247,7 +260,13 @@ async def on_agent_start(self, context: Any, agent: Any) -> None: root_prompt = _to_openai_agent_items(turns) try: - result = await Runner.run(root_agent, root_prompt, hooks=hooks) + root_max_turns = model_parameters(root.config).get("max_turns") + root_run_kwargs = ( + {"max_turns": root_max_turns} if root_max_turns is not None else {} + ) + result = await Runner.run( + root_agent, root_prompt, hooks=hooks, **root_run_kwargs + ) if span: span.set_status(SpanStatusCode.OK) except Exception as exc: diff --git a/packages/openai-agents/tests/test_handler.py b/packages/openai-agents/tests/test_handler.py index 4268ee01..f4c5586b 100644 --- a/packages/openai-agents/tests/test_handler.py +++ b/packages/openai-agents/tests/test_handler.py @@ -153,10 +153,16 @@ def run_streamed(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: return run_streamed +class FakeModelSettings: + def __init__(self, **kw: Any) -> None: + self.kwargs = kw + + def _fake_agents_module(run: Any = None, run_streamed: Any = None) -> Any: mod = SimpleNamespace() mod.FunctionTool = FakeFunctionTool mod.Agent = FakeAgent + mod.ModelSettings = FakeModelSettings class Runner: pass @@ -1389,6 +1395,97 @@ async def test_emits_no_content_by_default_on_the_streaming_path(self) -> None: # --------------------------------------------------------------------------- +class TestModelParametersForwarding: + async def test_snake_case_param_reaches_model_settings(self) -> None: + run_kwargs: dict[str, Any] = {} + + async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + run_kwargs["agent"] = agent + await _drive_turns(hooks, agent, prompt, [{"output": _text_output("hi")}]) + return FakeRunResult("done") + + agents_mod = _fake_agents_module(run=run) + config = _make_config( + instructions="Be helpful.", + model={"name": "gpt-4o", "parameters": {"top_p": 0.5}}, + ) + with _patched_agents(agents_mod): + await create_openai_agent_handler()(config, "q", {}, {}) + model_settings = run_kwargs["agent"].kwargs["model_settings"] + assert model_settings.kwargs["top_p"] == 0.5 + + async def test_max_turns_from_config_reaches_runner_run(self) -> None: + captured: dict[str, Any] = {} + + async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + captured["max_turns"] = kw.get("max_turns") + await _drive_turns(hooks, agent, prompt, [{"output": _text_output("hi")}]) + return FakeRunResult("done") + + agents_mod = _fake_agents_module(run=run) + config = _make_config( + instructions="Be helpful.", + model={"name": "gpt-4o", "parameters": {"max_turns": 3}}, + ) + with _patched_agents(agents_mod): + await create_openai_agent_handler()(config, "q", {}, {}) + assert captured["max_turns"] == 3 + + async def test_max_turns_from_config_reaches_runner_run_streamed(self) -> None: + captured: dict[str, Any] = {} + + def run_streamed(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + captured["max_turns"] = kw.get("max_turns") + return FakeStreamedResult( + agent, prompt, hooks, [{"output": _text_output("hi")}], "done" + ) + + agents_mod = _fake_agents_module(run_streamed=run_streamed) + config = _make_config( + instructions="Be helpful.", + model={"name": "gpt-4o", "parameters": {"max_turns": 4}}, + ) + with _patched_agents(agents_mod): + events = [] + gen = await create_openai_agent_handler().stream(config, "q", {}, {}) + async for event in gen: + events.append(event) + assert captured["max_turns"] == 4 + + async def test_max_turns_not_owned_by_model_settings(self) -> None: + run_kwargs: dict[str, Any] = {} + + async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + run_kwargs["agent"] = agent + await _drive_turns(hooks, agent, prompt, [{"output": _text_output("hi")}]) + return FakeRunResult("done") + + agents_mod = _fake_agents_module(run=run) + config = _make_config( + instructions="Be helpful.", + model={"name": "gpt-4o", "parameters": {"max_turns": 3}}, + ) + with _patched_agents(agents_mod): + await create_openai_agent_handler()(config, "q", {}, {}) + model_settings = run_kwargs["agent"].kwargs.get("model_settings") + assert model_settings is None or "max_turns" not in model_settings.kwargs + + async def test_call_unchanged_when_no_parameters_set(self) -> None: + run_kwargs: dict[str, Any] = {} + + async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + run_kwargs["agent"] = agent + run_kwargs["kw"] = kw + await _drive_turns(hooks, agent, prompt, [{"output": _text_output("hi")}]) + return FakeRunResult("done") + + agents_mod = _fake_agents_module(run=run) + with _patched_agents(agents_mod): + await create_openai_agent_handler()(CONFIG, "q", {}, {}) + assert "model_settings" not in run_kwargs["agent"].kwargs + assert run_kwargs["kw"].get("max_turns") is None + + class TestOutputFormat: def test_absent_output_format_no_change(self) -> None: assert build_output_type(None) is None diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index a404fdcb..ba8e6442 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -21,6 +21,7 @@ end_unfinished_spans, image_block_to_url, is_content_blocks, + model_parameters, parse_template, set_input_content_attributes, set_output_content_attributes, @@ -261,7 +262,17 @@ async def _call_impl( messages=root_messages, ) + extra_params = model_parameters(config) + for _owned_key in ( + "model", + "input", + "previous_response_id", + "tools", + "text", + ): + extra_params.pop(_owned_key, None) params: dict[str, Any] = { + **extra_params, "model": config["model"]["name"], "input": input_messages, } @@ -341,6 +352,7 @@ async def _call_impl( client, config, { + **extra_params, "model": config["model"]["name"], "previous_response_id": response.id, "input": tool_outputs, @@ -489,7 +501,17 @@ async def _stream_gen( tool_definitions=tool_definitions, ) + extra_params = model_parameters(config) + for _owned_key in ( + "model", + "input", + "previous_response_id", + "tools", + "text", + ): + extra_params.pop(_owned_key, None) stream_params: dict[str, Any] = { + **extra_params, "model": config["model"]["name"], "input": current_input, } diff --git a/packages/openai-messages/tests/test_handler.py b/packages/openai-messages/tests/test_handler.py index 4668976f..f9b169f5 100644 --- a/packages/openai-messages/tests/test_handler.py +++ b/packages/openai-messages/tests/test_handler.py @@ -1132,6 +1132,73 @@ async def test_rethrows_error(self, mock_openai: MagicMock) -> None: # --------------------------------------------------------------------------- +class TestModelParametersForwarding: + async def test_snake_case_param_reaches_provider( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"top_p": 0.5}}, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["top_p"] == 0.5 + + async def test_config_cannot_override_model_or_input( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": { + "model": "not-the-real-model", + "input": "not-the-real-input", + }, + }, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["model"] == CONFIG["model"]["name"] + assert kwargs["input"] != "not-the-real-input" + + async def test_call_unchanged_when_no_parameters_set( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + h = create_openai_messages_handler() + await h(CONFIG, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert set(kwargs.keys()) == {"model", "input"} + + async def test_streaming_forwards_snake_case_param( + self, mock_openai: MagicMock + ) -> None: + import launchdarkly_ai_openai_messages.spans as spans_mod + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + mock_openai.responses.stream = MagicMock( + return_value=_make_openai_stream_context(["hi"]) + ) + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"top_p": 0.3}}, + } + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_openai_messages_handler() + events = [e async for e in await h.stream(config, "q")] + assert events + stream_kwargs = mock_openai.responses.stream.call_args.kwargs + assert stream_kwargs["top_p"] == 0.3 + + class TestOutputFormat: async def test_absent_output_format_no_change(self, mock_openai: MagicMock) -> None: from launchdarkly_ai_openai_messages import create_openai_messages_handler From beabd791e7bf0cdebee6f9618964c8fec9f04f4c Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 12:13:34 -0400 Subject: [PATCH 02/26] fix(client): filter model.parameters to what each provider actually accepts model.parameters is a free-form dict the LaunchDarkly UI writes, offering keys that make sense across providers but that no single provider SDK accepts in full. Forwarding it unfiltered (added in a prior commit on this branch) raised TypeError before any request for several real configs: ClaudeAgentOptions has no temperature/top_p/top_k/max_tokens/ stop_sequences/tool_choice/metadata; the OpenAI Responses API has no max_tokens/frequency_penalty/presence_penalty/seed/n/stop/response_format/ logit_bias/logprobs/max_completion_tokens/audio/modalities/prediction; Anthropic's Messages API has no top-level effort. Adds launchdarkly_ai_server.parameter_forwarding, a shared filter used by every handler: accept-sets are derived once from the live provider type (inspect.signature for plain methods, dataclasses.fields for ClaudeAgentOptions/ModelSettings, pydantic model_fields + aliases for the LangChain chat models), never hand-listed, so an SDK's own drift is picked up automatically. transport/escape-hatch keys (extra_headers, extra_query, extra_body, timeout, extra_*) are stripped unconditionally regardless of what a signature accepts, and each call site may additionally exclude keys the API accepts but that would break the handler because the handler itself already decides that behaviour: stream/stream_options/background/ conversation/prompt for the OpenAI Responses API, stream for Anthropic Messages. Everything else the provider accepts is forwarded, including keys that are not strictly generation settings (store, user, safety_identifier, prompt_cache_key, prompt_cache_retention, include, context_management, metadata, service_tier, instructions, moderation). Two renames, applied before the accept filter: openai-messages maps max_tokens/max_completion_tokens to the Responses API's max_output_tokens (explicit max_output_tokens wins, then max_completion_tokens, then max_tokens); claude-messages maps a top-level effort into output_config.effort, unless the config already sets its own output_config.effort, which wins. Applied at every call site across all six handlers, including streaming and native-graph paths. claude-agents/openai-agents dataclass accept-sets are computed once at module load from the real SDK types, independent of each package's per-call importlib.import_module mocking in tests. LangChain's per-provider pydantic accept-sets are cached per class with functools.cache, since the constructor class is only known once a provider branch resolves langchain_openai/_anthropic/_aws. Updated the existing tests whose old assertions relied on unfiltered pass-through of a key the real SDK does not accept (tools forwarded to ChatOpenAI's constructor in two langchain-agents/messages tests) to give the mocked provider class an accurate model_fields shape and to reflect the value now being dropped. Verified: uv run pytest (1383 passed, 11 skipped), uv run mypy packages/*/src, uv run ruff check ., uv run ruff format --check .. uv.lock unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../launchdarkly_ai_claude_agents/handler.py | 13 +- .../native_graph.py | 6 +- packages/claude-agents/tests/test_handler.py | 66 +++++ .../handler.py | 59 +++- .../claude-messages/tests/test_handler.py | 113 ++++++++ .../src/launchdarkly_ai_server/__init__.py | 15 + .../parameter_forwarding.py | 163 +++++++++++ .../client/tests/test_parameter_forwarding.py | 159 +++++++++++ .../handler.py | 32 ++- .../native_graph.py | 6 + .../langchain-agents/tests/test_handler.py | 68 ++++- .../tests/test_native_graph.py | 2 + .../handler.py | 31 +- .../langchain-messages/tests/test_handler.py | 67 ++++- .../launchdarkly_ai_openai_agents/handler.py | 13 + .../native_graph.py | 10 +- packages/openai-agents/tests/test_handler.py | 51 ++++ .../handler.py | 56 +++- .../openai-messages/tests/test_handler.py | 267 ++++++++++++++++++ 19 files changed, 1179 insertions(+), 18 deletions(-) create mode 100644 packages/client/src/launchdarkly_ai_server/parameter_forwarding.py create mode 100644 packages/client/tests/test_parameter_forwarding.py diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index dc196e51..13e1d02a 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -35,12 +35,14 @@ ProviderHandler, SpanMessage, SpanMessagePart, + accepted_parameter_keys_from_dataclass, compose_history, config, content_to_text, create_handler, end_span_once, end_unfinished_spans, + filter_forwardable_parameters, model_parameters, parse_template, set_conversation_id_if_absent, @@ -67,6 +69,13 @@ tool_display_name, ) +#: Accepted keys derived once from ``ClaudeAgentOptions``'s own fields, never hand-maintained. The +#: SDK offers no ``temperature``/``top_p``/``top_k``/``max_tokens``/``stop_sequences``/ +#: ``tool_choice``/``metadata``, all of which the LaunchDarkly UI's model parameters panel offers +#: for other providers; forwarding one of those unfiltered raised ``TypeError`` before this filter +#: existed. +_CLAUDE_AGENT_OPTIONS_KEYS = accepted_parameter_keys_from_dataclass(ClaudeAgentOptions) + # --------------------------------------------------------------------------- # Tool wiring # --------------------------------------------------------------------------- @@ -480,7 +489,9 @@ def _build_query_options( **extra: Any, ) -> ClaudeAgentOptions: all_allowed = [*mcp_allowed_tools, *native_tool_names] - params = model_parameters(config) + params = filter_forwardable_parameters( + model_parameters(config), _CLAUDE_AGENT_OPTIONS_KEYS + ) for _owned_key in ( "model", "allowed_tools", diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index 3876adf5..28f0d6a2 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -16,6 +16,7 @@ GraphDefinition, GraphNode, NativeTool, + filter_forwardable_parameters, get_client, make_track_data, model_parameters, @@ -31,6 +32,7 @@ _HAS_OTEL = False from launchdarkly_ai_claude_agents.handler import ( + _CLAUDE_AGENT_OPTIONS_KEYS, _build_hooks, build_prompt, build_query_prompt, @@ -150,7 +152,9 @@ async def _run_query( hooks = _build_hooks(native_tool_map) - params = model_parameters(node.config) + params = filter_forwardable_parameters( + model_parameters(node.config), _CLAUDE_AGENT_OPTIONS_KEYS + ) for _owned_key in ( "model", "tools", diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index 760970db..d3f7d5ca 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -1490,6 +1490,72 @@ async def test_unset_when_no_parameters( options = await self._run_and_capture_options(BASE_CONFIG, monkeypatch) assert options.max_turns is None + async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``ClaudeAgentOptions`` has no ``temperature``/``top_p``/``top_k``/``max_tokens``/ + ``stop_sequences``/``tool_choice``/``metadata`` fields, all of which the LaunchDarkly UI's + model parameters panel offers for other providers. Forwarding one unfiltered raises + ``TypeError`` before any request is made; the filter must drop them instead. + """ + config = { + **BASE_CONFIG, + "model": { + **BASE_CONFIG["model"], + "parameters": { + "temperature": 0.2, + "top_p": 0.5, + "top_k": 10, + "max_tokens": 256, + "stop_sequences": ["STOP"], + "tool_choice": "auto", + "metadata": {"user_id": "u1"}, + "max_turns": 3, + }, + }, + } + options = await self._run_and_capture_options(config, monkeypatch) + assert options.max_turns == 3 + for rejected in ( + "temperature", + "top_p", + "top_k", + "max_tokens", + "stop_sequences", + "tool_choice", + "metadata", + ): + assert not hasattr(options, rejected) or getattr(options, rejected) is None + + async def test_transport_key_is_never_forwarded( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = { + **BASE_CONFIG, + "model": { + **BASE_CONFIG["model"], + "parameters": {"extra_body": {"secret": "value"}, "max_turns": 2}, + }, + } + options = await self._run_and_capture_options(config, monkeypatch) + assert options.max_turns == 2 + assert not hasattr(options, "extra_body") or options.extra_body is None + + async def test_temperature_top_p_and_max_turns_run_without_type_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A realistic combination of UI-offered keys must not raise, and the one real field + (``max_turns``) must still land.""" + config = { + **BASE_CONFIG, + "model": { + **BASE_CONFIG["model"], + "parameters": {"temperature": 0.3, "top_p": 0.8, "max_turns": 5}, + }, + } + options = await self._run_and_capture_options(config, monkeypatch) + assert options.max_turns == 5 + class TestFinishReasonMapping: async def test_tool_use_maps_to_tool_calls( diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index d68b6cc3..4364aa70 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -11,12 +11,14 @@ ProviderHandler, SpanMessage, SpanMessagePart, + accepted_parameter_keys_from_signature, compose_history, config, content_to_text, create_handler, end_span_once, end_unfinished_spans, + filter_forwardable_parameters, is_content_blocks, model_parameters, parse_template, @@ -44,12 +46,55 @@ ) try: - import anthropic as _anthropic_mod # noqa: F401 + import anthropic as _anthropic_mod _HAS_ANTHROPIC = True except ImportError: _HAS_ANTHROPIC = False +#: Accepted keys derived once from ``AsyncMessages.create``/``.stream``'s own signatures, never +#: hand-maintained. Neither has a ``**kwargs`` catch-all. The two differ slightly (``stream`` also +#: takes ``output_format``), so each call site below filters against the method it actually calls. +_MESSAGES_CREATE_KEYS = accepted_parameter_keys_from_signature( + _anthropic_mod.resources.messages.AsyncMessages.create +) +_MESSAGES_STREAM_KEYS = accepted_parameter_keys_from_signature( + _anthropic_mod.resources.messages.AsyncMessages.stream +) + +#: Keys the Messages API signature accepts but that would break this handler if a config set +#: them, because the handler itself already decides that behaviour. Everything else the API +#: accepts is forwarded, including keys that are not strictly generation settings (``metadata``, +#: ``service_tier``, ``container``, ``user_profile_id``, ``output_config``, ...). +_MESSAGES_EXCLUDED_KEYS = frozenset( + { + "stream", # the handler chooses blocking vs. streaming itself, not via a kwarg + } +) + + +def _rename_effort_to_output_config(params: dict[str, Any]) -> dict[str, Any]: + """Moves a top-level ``effort`` into ``output_config.effort``, the shape the Anthropic Messages + API actually accepts (there is no top-level ``effort`` parameter). + + An ``output_config`` the config itself already set wins: if it carries its own ``effort``, the + top-level one is dropped rather than overwriting it. This handler does not itself set + ``output_config`` (structured output goes through a system-prompt instruction instead, see + ``_build_messages``), so there is nothing here to merge with yet; a future call site that starts + setting ``output_config`` must merge so its own keys win and a config's ``effort`` survives. + """ + effort = params.pop("effort", None) + if effort is None: + return params + existing = params.get("output_config") + if isinstance(existing, dict) and "effort" in existing: + return params + params["output_config"] = { + **(existing if isinstance(existing, dict) else {}), + "effort": effort, + } + return params + def _build_tools(config_tools: dict[str, Any]) -> list[dict[str, Any]]: return [ @@ -202,7 +247,11 @@ async def _run_tool_loop( # difference predates this span work and changes what the model is offered, not what the span # reports, so it stays as it is: the catalog recorded below is the catalog actually sent. tools = _build_tools(config.get("tools") or {}) - extra_params = model_parameters(config) + extra_params = filter_forwardable_parameters( + _rename_effort_to_output_config(model_parameters(config)), + _MESSAGES_CREATE_KEYS, + _MESSAGES_EXCLUDED_KEYS, + ) max_tokens = extra_params.pop("max_tokens", 1024) for _owned_key in ("model", "messages", "system", "tools"): extra_params.pop(_owned_key, None) @@ -497,7 +546,11 @@ async def _stream_gen( tools = _build_tools(config.get("tools") or {}) tool_definitions = to_tool_definitions(tools) - extra_params = model_parameters(config) + extra_params = filter_forwardable_parameters( + _rename_effort_to_output_config(model_parameters(config)), + _MESSAGES_STREAM_KEYS, + _MESSAGES_EXCLUDED_KEYS, + ) max_tokens = extra_params.pop("max_tokens", 1024) for _owned_key in ("model", "messages", "system", "tools"): extra_params.pop(_owned_key, None) diff --git a/packages/claude-messages/tests/test_handler.py b/packages/claude-messages/tests/test_handler.py index d8d2753a..86c777cc 100644 --- a/packages/claude-messages/tests/test_handler.py +++ b/packages/claude-messages/tests/test_handler.py @@ -506,6 +506,119 @@ def _handler(request: httpx.Request) -> httpx.Response: assert captured["body"]["top_p"] == 0.4 assert captured["body"]["max_tokens"] == 256 + async def test_ui_key_the_sdk_rejects_is_dropped_without_raising( + self, mock_anthropic: MagicMock + ) -> None: + """``effort`` has no top-level equivalent on ``messages.create``; without the rename it + raises ``TypeError`` before this filter existed. Here it is set with no ``output_config``, + so the effort rename applies and the call must not raise. + """ + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"effort": "low"}}, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["output_config"] == {"effort": "low"} + + async def test_transport_key_is_never_forwarded( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": {"top_p": 0.5, "extra_body": {"secret": "value"}}, + }, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["top_p"] == 0.5 + assert "extra_body" not in call_kwargs + + async def test_stream_key_is_never_forwarded( + self, mock_anthropic: MagicMock + ) -> None: + """The handler picks blocking vs. streaming by which client method it calls, not by a + ``stream`` kwarg; a config setting it must not reach ``messages.create``.""" + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"stream": True}}, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert "stream" not in call_kwargs + + +class TestEffortRename: + async def test_explicit_output_config_effort_wins_over_top_level_effort( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": { + "effort": "low", + "output_config": {"effort": "high"}, + }, + }, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["output_config"] == {"effort": "high"} + + async def test_top_level_effort_moves_into_output_config_when_absent( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"effort": "medium"}}, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["output_config"] == {"effort": "medium"} + + async def test_top_level_effort_merges_into_an_output_config_without_effort( + self, mock_anthropic: MagicMock + ) -> None: + """An ``output_config`` present without its own ``effort`` keeps its other keys and gains + the top-level ``effort``.""" + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": { + "effort": "low", + "output_config": {"some_other_key": "kept"}, + }, + }, + } + h = create_claude_messages_handler() + await h(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert call_kwargs["output_config"] == { + "some_other_key": "kept", + "effort": "low", + } + class TestToolExecutionLoop: async def test_single_tool_call_then_done(self, mock_anthropic: MagicMock) -> None: diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index b7f50efb..02ceeb26 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -54,6 +54,14 @@ inspect_config, shutdown, ) +from .parameter_forwarding import ( + accepted_parameter_keys_from_dataclass, + accepted_parameter_keys_from_pydantic_model, + accepted_parameter_keys_from_signature, + filter_forwardable_parameters, + is_transport_parameter, + strip_transport_parameters, +) from .registry import ( Registry, compose, @@ -195,6 +203,13 @@ "RunSummary", "Scorer", "init_evaluations", + # parameter_forwarding + "accepted_parameter_keys_from_dataclass", + "accepted_parameter_keys_from_pydantic_model", + "accepted_parameter_keys_from_signature", + "filter_forwardable_parameters", + "is_transport_parameter", + "strip_transport_parameters", # utils "create_handler", "make_track_data", diff --git a/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py b/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py new file mode 100644 index 00000000..beee431f --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py @@ -0,0 +1,163 @@ +""" +Shared filtering for ``model.parameters`` before it reaches a provider SDK call. + +``model.parameters`` is a free-form dict the LaunchDarkly UI writes, offering keys that make +sense across providers (``temperature``, ``top_p``, ``max_tokens``, ``tool_choice``, ...). No +single provider SDK accepts all of them: passing a key a plain method or a dataclass constructor +does not declare raises ``TypeError`` before any request is made, and passing an unknown key to a +pydantic model is merely a landmine for the day a stricter model config removes that tolerance. + +Every handler therefore filters ``model_parameters(config)`` down to the keys the specific +provider entry point actually accepts before merging it into its own call kwargs. This module is +the one place that derives an accept-set from a live provider type (never a hand-maintained list, +which would drift the moment an SDK adds or removes a parameter) and the one place that strips +transport-only keys regardless of what the accept-set says. +""" + +from __future__ import annotations + +import dataclasses +import inspect +from collections.abc import Callable, Mapping +from typing import Any + +#: Keys that are never forwarded to a provider call, even when the callable's own signature +#: happens to accept them. These are transport or escape-hatch concerns (request timeouts, raw +#: HTTP overrides), not model settings, and forwarding one from ``model.parameters`` would let a +#: config silently rewrite the transport for every call under it. +_TRANSPORT_PARAMETER_KEYS = frozenset({"timeout"}) +_TRANSPORT_PARAMETER_PREFIX = "extra_" + + +def is_transport_parameter(key: str) -> bool: + """Whether *key* is a transport/escape-hatch parameter that must never be forwarded. + + Matches the exact names ``timeout`` and any key prefixed with ``extra_`` (``extra_headers``, + ``extra_query``, ``extra_body``, ``extra_args``, and any future ``extra_*`` addition). + """ + return key in _TRANSPORT_PARAMETER_KEYS or key.startswith( + _TRANSPORT_PARAMETER_PREFIX + ) + + +def strip_transport_parameters(params: Mapping[str, Any]) -> dict[str, Any]: + """Returns a copy of *params* with every transport/escape-hatch key removed. + + Always applied before the accept-set filter below, so a provider call signature that happens + to declare ``timeout`` or ``extra_body`` can never let a config value reach it. + """ + return {k: v for k, v in params.items() if not is_transport_parameter(k)} + + +def accepted_parameter_keys_from_signature(fn: Callable[..., Any]) -> frozenset[str]: + """Derives the accepted keyword-argument names from a plain callable's signature. + + For the provider methods this SDK forwards to directly (``AsyncAnthropic.messages.create``, + ``AsyncOpenAI.responses.create``, and their streaming counterparts), the accepted keys are + exactly the callable's parameter names, read once via :func:`inspect.signature` rather than + hand-maintained, so a parameter the SDK adds or removes is picked up automatically. + + Raises ``ValueError`` if the signature has a ``**kwargs`` catch-all: that shape has no + derivable accept-set, and treating it as "accepts everything" would defeat the point of this + module. Callers should surface which callable that was rather than silently letting everything + through. + """ + signature = inspect.signature(fn) + accepted: set[str] = set() + for name, param in signature.parameters.items(): + if name == "self": + continue + if param.kind is inspect.Parameter.VAR_KEYWORD: + raise ValueError( + f"{fn!r} accepts **{name}; no accept-set can be derived from its signature" + ) + if param.kind is inspect.Parameter.VAR_POSITIONAL: + continue + accepted.add(name) + return frozenset(accepted) + + +def accepted_parameter_keys_from_dataclass(cls: type) -> frozenset[str]: + """Derives the accepted keys from a dataclass's field names. + + Used for ``ClaudeAgentOptions`` (claude-agents) and ``ModelSettings`` (openai-agents), both of + which are plain dataclasses with no ``**kwargs`` catch-all. + """ + return frozenset(f.name for f in dataclasses.fields(cls)) + + +def _alias_strings(field: Any) -> list[str]: + """Every string alias a pydantic ``FieldInfo``-like object declares. + + Duck-typed against ``.alias`` and ``.validation_alias`` rather than importing pydantic, so a + lightweight test double built the same shape works identically to a real ``FieldInfo``. + ``validation_alias`` may itself be a plain string, or an ``AliasChoices``/``AliasPath`` whose + ``.choices`` holds the individual alias strings; anything else is ignored. + """ + aliases: list[str] = [] + alias = getattr(field, "alias", None) + if isinstance(alias, str): + aliases.append(alias) + validation_alias = getattr(field, "validation_alias", None) + if isinstance(validation_alias, str): + aliases.append(validation_alias) + else: + choices = getattr(validation_alias, "choices", None) + if choices: + aliases.extend(choice for choice in choices if isinstance(choice, str)) + return aliases + + +def accepted_parameter_keys_from_pydantic_model(cls: Any) -> frozenset[str]: + """Derives the accepted keys from a pydantic model's fields and their aliases. + + Used for the LangChain chat model classes (``ChatOpenAI``, ``ChatAnthropic``, + ``ChatBedrockConverse``), which populate by alias (``populate_by_name=True``, + ``validate_by_alias=True``): a constructor kwarg may name either the field itself or one of + its aliases, so both must be in the accept-set for a value to actually land. + + Reads ``cls.model_fields`` (pydantic v2) rather than importing pydantic, so this also accepts + any test double exposing the same shape. Raises ``ValueError`` if *cls* has no usable + ``model_fields`` mapping. + """ + fields = getattr(cls, "model_fields", None) + if not isinstance(fields, Mapping): + raise ValueError( + f"{cls!r} has no usable model_fields; cannot derive an accept-set from it" + ) + accepted: set[str] = set() + for name, field in fields.items(): + accepted.add(name) + accepted.update(_alias_strings(field)) + return frozenset(accepted) + + +def filter_forwardable_parameters( + params: Mapping[str, Any], + accepted_keys: frozenset[str], + excluded_keys: frozenset[str] = frozenset(), +) -> dict[str, Any]: + """Returns the subset of *params* that is safe to forward to a provider call. + + Three passes, in order: transport/escape-hatch keys are stripped unconditionally (see + :func:`strip_transport_parameters`), then *excluded_keys* are stripped, then whatever remains is + filtered down to *accepted_keys*. + + *excluded_keys* is for a key the provider's own signature accepts but that would break the + handler if a config set it, because the handler itself decides that behaviour (for example + ``stream``, which the handler picks by calling a blocking or a streaming method, not by a + kwarg). This is a narrower, opt-in exclusion than the handler-owned keys a call site pops after + this filter runs (``model``, ``messages``/``input``, ``tools``, ...): those are always removed, + this is provider-specific and each call site decides its own set. The rule for both this and + the handler-owned set is the same: exclude only what would break the handler, forward + everything else the provider accepts, even keys that are not strictly generation settings. + A key the UI offers but the specific provider entry point does not declare is dropped rather + than raising, matching this SDK's behaviour before ``model.parameters`` forwarding existed: + an unrecognised tuning value is silently ignored, not a hard failure. + """ + transport_free = strip_transport_parameters(params) + return { + k: v + for k, v in transport_free.items() + if k not in excluded_keys and k in accepted_keys + } diff --git a/packages/client/tests/test_parameter_forwarding.py b/packages/client/tests/test_parameter_forwarding.py new file mode 100644 index 00000000..1e6529ad --- /dev/null +++ b/packages/client/tests/test_parameter_forwarding.py @@ -0,0 +1,159 @@ +""" +Tests for the shared model.parameters filter: is_transport_parameter, +strip_transport_parameters, accepted_parameter_keys_from_signature, +accepted_parameter_keys_from_dataclass, accepted_parameter_keys_from_pydantic_model, +filter_forwardable_parameters. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +import pytest + +from launchdarkly_ai_server import ( + accepted_parameter_keys_from_dataclass, + accepted_parameter_keys_from_pydantic_model, + accepted_parameter_keys_from_signature, + filter_forwardable_parameters, + is_transport_parameter, + strip_transport_parameters, +) + + +class TestIsTransportParameter: + @pytest.mark.parametrize( + "key", + [ + "timeout", + "extra_headers", + "extra_query", + "extra_body", + "extra_args", + "extra_x", + ], + ) + def test_matches_known_transport_keys(self, key: str) -> None: + assert is_transport_parameter(key) + + @pytest.mark.parametrize("key", ["temperature", "top_p", "model", "extraordinary"]) + def test_does_not_match_generation_keys(self, key: str) -> None: + assert not is_transport_parameter(key) + + +class TestStripTransportParameters: + def test_removes_every_transport_key(self) -> None: + params = { + "temperature": 0.5, + "timeout": 30, + "extra_body": {"a": 1}, + "extra_headers": {"h": "v"}, + } + assert strip_transport_parameters(params) == {"temperature": 0.5} + + def test_does_not_mutate_input(self) -> None: + params = {"timeout": 30, "temperature": 0.5} + strip_transport_parameters(params) + assert params == {"timeout": 30, "temperature": 0.5} + + def test_empty_input_returns_empty(self) -> None: + assert strip_transport_parameters({}) == {} + + +class TestAcceptedParameterKeysFromSignature: + def test_derives_names_from_plain_function(self) -> None: + def fn(a: int, b: str = "x") -> None: ... + + assert accepted_parameter_keys_from_signature(fn) == frozenset({"a", "b"}) + + def test_excludes_self(self) -> None: + class C: + def method(self, a: int) -> None: ... + + assert accepted_parameter_keys_from_signature(C.method) == frozenset({"a"}) + + def test_excludes_var_positional(self) -> None: + def fn(a: int, *args: Any) -> None: ... + + assert accepted_parameter_keys_from_signature(fn) == frozenset({"a"}) + + def test_raises_on_var_keyword_catch_all(self) -> None: + def fn(a: int, **kwargs: Any) -> None: ... + + with pytest.raises(ValueError, match="kwargs"): + accepted_parameter_keys_from_signature(fn) + + +class TestAcceptedParameterKeysFromDataclass: + def test_derives_field_names(self) -> None: + @dataclass + class Options: + a: int = 0 + b: str = "" + + assert accepted_parameter_keys_from_dataclass(Options) == frozenset({"a", "b"}) + + +class TestAcceptedParameterKeysFromPydanticModel: + def test_includes_field_names_and_string_aliases(self) -> None: + cls = SimpleNamespace( + model_fields={ + "model_name": SimpleNamespace(alias="model", validation_alias="model"), + "temperature": SimpleNamespace(alias=None, validation_alias=None), + } + ) + keys = accepted_parameter_keys_from_pydantic_model(cls) + assert keys == frozenset({"model_name", "model", "temperature"}) + + def test_reads_alias_choices(self) -> None: + cls = SimpleNamespace( + model_fields={ + "field_a": SimpleNamespace( + alias=None, + validation_alias=SimpleNamespace( + choices=["alias_one", "alias_two"] + ), + ), + } + ) + keys = accepted_parameter_keys_from_pydantic_model(cls) + assert keys == frozenset({"field_a", "alias_one", "alias_two"}) + + def test_raises_when_model_fields_missing(self) -> None: + with pytest.raises(ValueError, match="model_fields"): + accepted_parameter_keys_from_pydantic_model(SimpleNamespace()) + + def test_raises_when_model_fields_not_a_mapping(self) -> None: + with pytest.raises(ValueError, match="model_fields"): + accepted_parameter_keys_from_pydantic_model( + SimpleNamespace(model_fields="not-a-mapping") + ) + + +class TestFilterForwardableParameters: + def test_keeps_only_accepted_keys(self) -> None: + params = {"temperature": 0.5, "unknown": 1} + result = filter_forwardable_parameters(params, frozenset({"temperature"})) + assert result == {"temperature": 0.5} + + def test_strips_transport_keys_even_if_accepted(self) -> None: + params = {"temperature": 0.5, "timeout": 30, "extra_body": {"a": 1}} + accepted = frozenset({"temperature", "timeout", "extra_body"}) + result = filter_forwardable_parameters(params, accepted) + assert result == {"temperature": 0.5} + + def test_strips_excluded_keys_even_if_accepted(self) -> None: + params = {"temperature": 0.5, "stream": True} + accepted = frozenset({"temperature", "stream"}) + result = filter_forwardable_parameters(params, accepted, frozenset({"stream"})) + assert result == {"temperature": 0.5} + + def test_empty_params_returns_empty(self) -> None: + assert filter_forwardable_parameters({}, frozenset({"temperature"})) == {} + + def test_does_not_mutate_input(self) -> None: + params = {"temperature": 0.5, "unknown": 1} + filter_forwardable_parameters(params, frozenset({"temperature"})) + assert params == {"temperature": 0.5, "unknown": 1} 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 4617bbb4..649b99e7 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import functools import json from collections.abc import AsyncGenerator from typing import Any @@ -16,12 +17,14 @@ ProviderHandler, SpanMessage, SpanMessagePart, + accepted_parameter_keys_from_pydantic_model, compose_history, config, create_handler, create_run_usage, end_span_once, end_unfinished_spans, + filter_forwardable_parameters, lang_chain_content_text, lang_chain_span_messages, lang_chain_span_usage, @@ -44,6 +47,17 @@ ) +@functools.cache +def pydantic_accept_keys(model_cls: Any) -> frozenset[str]: + """Caches the accept-set for a LangChain chat model class, keyed by the class object itself. + + Computed once per class rather than per call: ``ChatOpenAI``/``ChatAnthropic``/ + ``ChatBedrockConverse`` field introspection is cheap but there is no reason to repeat it on + every invocation of a hot path. + """ + return accepted_parameter_keys_from_pydantic_model(model_cls) + + def _build_agent_tools( config_tools: dict[str, Any], tool_handlers: dict[str, Any], @@ -171,12 +185,16 @@ def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: def _model_constructor_kwargs( - config: AiConfigRep, fallback_name: str + config: AiConfigRep, fallback_name: str, model_cls: Any = None ) -> dict[str, Any]: parameters = model_parameters(config) provider = str((config.get("provider") or {}).get("name") or "").lower() if provider == "bedrock": parameters.pop("tools", None) + if model_cls is not None and parameters: + parameters = filter_forwardable_parameters( + parameters, pydantic_accept_keys(model_cls) + ) parameters["model"] = _resolved_model_name(config, fallback_name) return parameters @@ -199,7 +217,9 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: if provider == "anthropic": lc_anthropic = importlib.import_module("langchain_anthropic") return lc_anthropic.ChatAnthropic( - **_model_constructor_kwargs(config, "claude-3-5-sonnet-20241022") + **_model_constructor_kwargs( + config, "claude-3-5-sonnet-20241022", lc_anthropic.ChatAnthropic + ) ) if provider == "bedrock": try: @@ -209,9 +229,13 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: "Using Bedrock models requires langchain-aws. " "Install it with: pip install langchain-aws" ) from exc - return lc_aws.ChatBedrockConverse(**_model_constructor_kwargs(config, "")) + return lc_aws.ChatBedrockConverse( + **_model_constructor_kwargs(config, "", lc_aws.ChatBedrockConverse) + ) lc_openai = importlib.import_module("langchain_openai") - return lc_openai.ChatOpenAI(**_model_constructor_kwargs(config, "gpt-4o")) + return lc_openai.ChatOpenAI( + **_model_constructor_kwargs(config, "gpt-4o", lc_openai.ChatOpenAI) + ) async def _resolve_base_model(config: AiConfigRep, llm: Any) -> Any: diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index 2cb9b715..b2ab320f 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -16,6 +16,7 @@ GraphNode, NativeTool, compose_history, + filter_forwardable_parameters, get_client, make_track_data, model_parameters, @@ -23,6 +24,7 @@ to_ld_context, ) +from .handler import pydantic_accept_keys from .messages import to_lang_chain_messages try: @@ -203,6 +205,10 @@ async def _traverse_node(node: GraphNode) -> None: lc_openai = importlib.import_module("langchain_openai") model_cfg = node.config.get("model") or {} kwargs = model_parameters(node.config) + if kwargs: + kwargs = filter_forwardable_parameters( + kwargs, pydantic_accept_keys(lc_openai.ChatOpenAI) + ) kwargs["model"] = model_cfg.get("name") or "gpt-4o" chat_model = lc_openai.ChatOpenAI(**kwargs) diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index 1861a1ed..4f0d45a2 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -13,7 +13,9 @@ import pydantic import pytest +from langchain_anthropic import ChatAnthropic as _REAL_CHAT_ANTHROPIC from langchain_core.language_models.chat_models import BaseChatModel +from langchain_openai import ChatOpenAI as _REAL_CHAT_OPENAI import launchdarkly_ai_langchain_agents.handler as handler_mod import launchdarkly_ai_langchain_agents.spans as spans_mod @@ -23,6 +25,16 @@ create_langchain_agents_handler, ) +# ``langchain_aws`` is not a dependency of this package's test environment, so a mocked +# ``ChatBedrockConverse`` gets a minimal hand-built ``model_fields`` shape (field name -> a +# duck-typed stand-in exposing ``.alias``/``.validation_alias``, just like a real pydantic +# ``FieldInfo``) covering the parameters these tests actually forward. +_FAKE_BEDROCK_FIELDS = { + "temperature": SimpleNamespace(alias=None, validation_alias=None), + "top_p": SimpleNamespace(alias=None, validation_alias=None), + "max_tokens": SimpleNamespace(alias=None, validation_alias=None), +} + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -2154,6 +2166,54 @@ async def test_a_reply_in_content_blocks_still_reaches_the_root(self) -> None: assert "a typed block" in str(rec.root.attributes["gen_ai.output.messages"]) +class TestModelParametersForwarding: + @pytest.mark.asyncio + async def test_ui_key_the_sdk_rejects_is_dropped_without_raising(self) -> None: + """``tools`` is not a ``ChatOpenAI`` constructor field (tools are bound via + ``bind_tools`` at call time); forwarding it unfiltered is a landmine, not a crash today + (pydantic ignores unknown constructor kwargs), but this handler must not rely on that.""" + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("answer")]) + ctor = MagicMock(return_value=llm) + ctor.model_fields = _REAL_CHAT_OPENAI.model_fields + cfg = { + **BASE_CONFIG, + "model": { + "name": "gpt-4o", + "parameters": {"temperature": 0.2, "tools": [{"name": "x"}]}, + }, + } + with ( + ctx, + patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_agents_handler()(cfg, "q") + assert ctor.call_args.kwargs == {"temperature": 0.2, "model": "gpt-4o"} + + @pytest.mark.asyncio + async def test_transport_key_is_never_forwarded(self) -> None: + ctx, _rec = _recording() + llm = _FakeToolModel(replies=[_ai_message("answer")]) + ctor = MagicMock(return_value=llm) + ctor.model_fields = _REAL_CHAT_OPENAI.model_fields + cfg = { + **BASE_CONFIG, + "model": { + "name": "gpt-4o", + "parameters": { + "temperature": 0.2, + "extra_body": {"secret": "value"}, + }, + }, + } + with ( + ctx, + patch.dict("sys.modules", {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_agents_handler()(cfg, "q") + assert ctor.call_args.kwargs == {"temperature": 0.2, "model": "gpt-4o"} + + class TestModelSource: @pytest.mark.asyncio async def test_factory_receives_config_and_returned_model_is_used(self) -> None: @@ -2201,9 +2261,14 @@ async def test_prebuilt_instance_is_used_as_is(self) -> None: @pytest.mark.asyncio async def test_default_openai_constructor_receives_parameters(self) -> None: + """``tools`` is dropped: it is not a ``ChatOpenAI`` constructor field (tools are bound via + ``bind_tools`` at call time, not passed to the constructor), so the UI offering it must not + raise or silently no-op — it is filtered out before this handler ever reaches the + constructor.""" ctx, _rec = _recording() llm = _FakeToolModel(replies=[_ai_message("default-openai")]) ctor = MagicMock(return_value=llm) + ctor.model_fields = _REAL_CHAT_OPENAI.model_fields cfg = { **BASE_CONFIG, "model": { @@ -2223,7 +2288,6 @@ 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", } @@ -2232,6 +2296,7 @@ async def test_default_anthropic_constructor_receives_parameters(self) -> None: ctx, _rec = _recording() llm = _FakeToolModel(replies=[_ai_message("default-anthropic")]) ctor = MagicMock(return_value=llm) + ctor.model_fields = _REAL_CHAT_ANTHROPIC.model_fields cfg = { **BASE_CONFIG, "provider": {"name": "Anthropic"}, @@ -2256,6 +2321,7 @@ async def test_bedrock_region_is_prepended_to_the_default_constructor( ctx, _rec = _recording() llm = _FakeToolModel(replies=[_ai_message("bedrock")]) ctor = MagicMock(return_value=llm) + ctor.model_fields = _FAKE_BEDROCK_FIELDS cfg = { **BASE_CONFIG, "provider": {"name": "Bedrock"}, diff --git a/packages/langchain-agents/tests/test_native_graph.py b/packages/langchain-agents/tests/test_native_graph.py index 9191a537..5fce7321 100644 --- a/packages/langchain-agents/tests/test_native_graph.py +++ b/packages/langchain-agents/tests/test_native_graph.py @@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from langchain_openai import ChatOpenAI as _REAL_CHAT_OPENAI from launchdarkly_ai_langchain_agents.native_graph import _extract_usage, to_lang_graph from launchdarkly_ai_server import GraphDefinition, GraphEdge, GraphNode @@ -168,6 +169,7 @@ def compile(self) -> Any: mock_lc_openai = MagicMock() mock_lc_openai.ChatOpenAI = MagicMock(return_value=mock_chat_model) + mock_lc_openai.ChatOpenAI.model_fields = _REAL_CHAT_OPENAI.model_fields mock_gm = MagicMock() mock_gm.add_messages = MagicMock(return_value=MagicMock()) 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 ddf686d5..6c5a761c 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import functools import json from collections.abc import AsyncGenerator from types import SimpleNamespace @@ -13,12 +14,14 @@ SpanMessage, SpanMessagePart, SpanUsage, + accepted_parameter_keys_from_pydantic_model, compose_history, config, create_handler, create_run_usage, end_span_once, end_unfinished_spans, + filter_forwardable_parameters, image_block_to_url, is_content_blocks, lang_chain_content_text, @@ -47,6 +50,16 @@ ) +@functools.cache +def pydantic_accept_keys(model_cls: Any) -> frozenset[str]: + """Caches the accept-set for a LangChain chat model class, keyed by the class object itself. + + Computed once per class rather than per call: field introspection is cheap but there is no + reason to repeat it on every invocation of a hot path. + """ + return accepted_parameter_keys_from_pydantic_model(model_cls) + + def _build_tools(config_tools: dict[str, Any]) -> list[dict[str, Any]]: # Not filtered to the tools that have a registered handler, unlike the TypeScript SDK's # `buildTools`. That difference predates this span work and changes what the model is offered, @@ -227,12 +240,16 @@ def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: def _model_constructor_kwargs( - config: AiConfigRep, fallback_name: str + config: AiConfigRep, fallback_name: str, model_cls: Any = None ) -> dict[str, Any]: parameters = model_parameters(config) provider = str((config.get("provider") or {}).get("name") or "").lower() if provider == "bedrock": parameters.pop("tools", None) + if model_cls is not None and parameters: + parameters = filter_forwardable_parameters( + parameters, pydantic_accept_keys(model_cls) + ) # 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 @@ -254,7 +271,9 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: if provider == "anthropic": lc_anthropic = importlib.import_module("langchain_anthropic") return lc_anthropic.ChatAnthropic( - **_model_constructor_kwargs(config, "claude-3-5-sonnet-20241022") + **_model_constructor_kwargs( + config, "claude-3-5-sonnet-20241022", lc_anthropic.ChatAnthropic + ) ) if provider == "bedrock": try: @@ -264,9 +283,13 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: "Using Bedrock models requires langchain-aws. " "Install it with: pip install langchain-aws" ) from exc - return lc_aws.ChatBedrockConverse(**_model_constructor_kwargs(config, "")) + return lc_aws.ChatBedrockConverse( + **_model_constructor_kwargs(config, "", lc_aws.ChatBedrockConverse) + ) lc_openai = importlib.import_module("langchain_openai") - return lc_openai.ChatOpenAI(**_model_constructor_kwargs(config, "gpt-4o")) + return lc_openai.ChatOpenAI( + **_model_constructor_kwargs(config, "gpt-4o", lc_openai.ChatOpenAI) + ) async def _resolve_base_model(config: AiConfigRep, llm: Any, importlib: Any) -> Any: diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index 1b30bc6d..80774bdd 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -8,10 +8,21 @@ import json import sys from collections.abc import AsyncGenerator +from types import SimpleNamespace from typing import Any, ClassVar from unittest.mock import AsyncMock, MagicMock, patch import pytest +from langchain_anthropic import ChatAnthropic as _REAL_CHAT_ANTHROPIC +from langchain_openai import ChatOpenAI as _REAL_CHAT_OPENAI + +# ``langchain_aws`` is not a dependency of this package's test environment, so a mocked +# ``ChatBedrockConverse`` gets a minimal hand-built ``model_fields`` shape (field name -> a +# duck-typed stand-in exposing ``.alias``/``.validation_alias``, just like a real pydantic +# ``FieldInfo``) covering the parameters these tests actually forward. +_FAKE_BEDROCK_FIELDS = { + "temperature": SimpleNamespace(alias=None, validation_alias=None), +} # --------------------------------------------------------------------------- # Fake LangChain message helpers @@ -2300,6 +2311,55 @@ def _counting(span: Any, capture: bool, *a: Any, **k: Any) -> None: assert calls["n"] == 0 +class TestModelParametersForwarding: + @pytest.mark.asyncio + async def test_ui_key_the_sdk_rejects_is_dropped_without_raising(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("answer") + ctor = MagicMock(return_value=llm) + ctor.model_fields = _REAL_CHAT_OPENAI.model_fields + cfg = { + **CONFIG, + "model": { + "name": "gpt-4o", + "parameters": {"temperature": 0.2, "tools": [{"name": "x"}]}, + }, + } + with ( + ctx, + patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert ctor.call_args.kwargs == {"temperature": 0.2, "model": "gpt-4o"} + + @pytest.mark.asyncio + async def test_transport_key_is_never_forwarded(self) -> None: + ctx, _rec = _recording() + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + llm = _make_llm("answer") + ctor = MagicMock(return_value=llm) + ctor.model_fields = _REAL_CHAT_OPENAI.model_fields + cfg = { + **CONFIG, + "model": { + "name": "gpt-4o", + "parameters": { + "temperature": 0.2, + "extra_body": {"secret": "value"}, + }, + }, + } + with ( + ctx, + patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), + ): + await create_langchain_messages_handler()(cfg, "q", {}, {}) + assert ctor.call_args.kwargs == {"temperature": 0.2, "model": "gpt-4o"} + + class TestModelSource: @pytest.mark.asyncio async def test_factory_receives_config_and_returned_model_is_used(self) -> None: @@ -2362,6 +2422,7 @@ async def test_default_openai_constructor_receives_parameters(self) -> None: llm = _make_llm("default-openai") ctor = MagicMock(return_value=llm) + ctor.model_fields = _REAL_CHAT_OPENAI.model_fields cfg = { **CONFIG, "model": { @@ -2378,10 +2439,12 @@ async def test_default_openai_constructor_receives_parameters(self) -> None: patch.dict(sys.modules, {"langchain_openai": MagicMock(ChatOpenAI=ctor)}), ): await create_langchain_messages_handler()(cfg, "q", {}, {}) + # ``tools`` is dropped: it is not a ``ChatOpenAI`` constructor field (tools are bound via + # ``bind_tools`` at call time), so the UI offering it must be filtered out rather than + # forwarded or raising. assert ctor.call_args.kwargs == { "temperature": 0.2, "max_tokens": 512, - "tools": [{"name": "openai-tool"}], "model": "gpt-4o", } @@ -2392,6 +2455,7 @@ async def test_default_anthropic_constructor_receives_parameters(self) -> None: llm = _make_llm("default-anthropic") ctor = MagicMock(return_value=llm) + ctor.model_fields = _REAL_CHAT_ANTHROPIC.model_fields cfg = { **CONFIG, "provider": {"name": "Anthropic"}, @@ -2422,6 +2486,7 @@ async def test_bedrock_region_is_prepended_to_the_default_constructor( llm = _make_llm("bedrock") ctor = MagicMock(return_value=llm) + ctor.model_fields = _FAKE_BEDROCK_FIELDS cfg = { **CONFIG, "provider": {"name": "Bedrock"}, diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index 8ef1d2b4..c0ec362c 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -23,12 +23,15 @@ from collections.abc import AsyncGenerator from typing import Any +from agents import ModelSettings as _ModelSettings + from launchdarkly_ai_server import ( AiConfigRep, LDContext, ProviderHandler, RunUsage, SpanUsage, + accepted_parameter_keys_from_dataclass, compose_history, config, content_to_text, @@ -36,6 +39,7 @@ create_run_usage, end_span_once, end_unfinished_spans, + filter_forwardable_parameters, image_block_to_url, model_parameters, parse_template, @@ -72,6 +76,12 @@ except ImportError: # pragma: no cover - `agents` is a hard dependency of this package _RunHooksBase = object # type: ignore[assignment,misc] +#: Accepted keys derived once from the real ``agents.ModelSettings`` dataclass, never +#: hand-maintained. Independent of this handler's per-call, mocked ``importlib.import_module("agents")`` +#: used elsewhere for actually building the ``Agent``/``ModelSettings`` instances: this is the one +#: place that needs the real SDK type, not a test double. +_MODEL_SETTINGS_KEYS = accepted_parameter_keys_from_dataclass(_ModelSettings) + def _build_agent_tools( config_tools: dict[str, Any], @@ -214,6 +224,9 @@ def _build_agent_and_prompt( model_settings_params = model_parameters(config) # `max_turns` is a `Runner.run` option, not a `ModelSettings` field. model_settings_params.pop("max_turns", None) + model_settings_params = filter_forwardable_parameters( + model_settings_params, _MODEL_SETTINGS_KEYS + ) agent = Agent( name="assistant", model=config.get("model", {}).get("name", "gpt-4o"), diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index fe56dc2a..c00f637c 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -16,6 +16,7 @@ GraphNode, NativeTool, compose_history, + filter_forwardable_parameters, get_client, make_track_data, model_parameters, @@ -23,7 +24,11 @@ to_ld_context, ) -from .handler import _parse_message_content, _to_openai_agent_items +from .handler import ( + _MODEL_SETTINGS_KEYS, + _parse_message_content, + _to_openai_agent_items, +) try: from opentelemetry import trace @@ -175,6 +180,9 @@ async def _visit(node_key: str) -> None: node_model_settings_params = model_parameters(node.config) # `max_turns` is a `Runner.run` option, not a `ModelSettings` field. node_model_settings_params.pop("max_turns", None) + node_model_settings_params = filter_forwardable_parameters( + node_model_settings_params, _MODEL_SETTINGS_KEYS + ) agent = Agent( name=agent_name, model=node.config.get("model", {}).get("name", "gpt-4o"), diff --git a/packages/openai-agents/tests/test_handler.py b/packages/openai-agents/tests/test_handler.py index f4c5586b..1deb7df9 100644 --- a/packages/openai-agents/tests/test_handler.py +++ b/packages/openai-agents/tests/test_handler.py @@ -1485,6 +1485,57 @@ async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: assert "model_settings" not in run_kwargs["agent"].kwargs assert run_kwargs["kw"].get("max_turns") is None + async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising(self) -> None: + """Neither is a field of ``agents.ModelSettings``; forwarding one unfiltered raises + ``TypeError`` before this filter existed.""" + run_kwargs: dict[str, Any] = {} + + async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + run_kwargs["agent"] = agent + await _drive_turns(hooks, agent, prompt, [{"output": _text_output("hi")}]) + return FakeRunResult("done") + + agents_mod = _fake_agents_module(run=run) + config = _make_config( + instructions="Be helpful.", + model={ + "name": "gpt-4o", + "parameters": { + "top_p": 0.5, + "stop_sequences": ["STOP"], + "seed": 42, + }, + }, + ) + with _patched_agents(agents_mod): + await create_openai_agent_handler()(config, "q", {}, {}) + model_settings = run_kwargs["agent"].kwargs["model_settings"] + assert model_settings.kwargs["top_p"] == 0.5 + assert "stop_sequences" not in model_settings.kwargs + assert "seed" not in model_settings.kwargs + + async def test_transport_key_is_never_forwarded(self) -> None: + run_kwargs: dict[str, Any] = {} + + async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + run_kwargs["agent"] = agent + await _drive_turns(hooks, agent, prompt, [{"output": _text_output("hi")}]) + return FakeRunResult("done") + + agents_mod = _fake_agents_module(run=run) + config = _make_config( + instructions="Be helpful.", + model={ + "name": "gpt-4o", + "parameters": {"top_p": 0.5, "extra_body": {"secret": "value"}}, + }, + ) + with _patched_agents(agents_mod): + await create_openai_agent_handler()(config, "q", {}, {}) + model_settings = run_kwargs["agent"].kwargs["model_settings"] + assert model_settings.kwargs["top_p"] == 0.5 + assert "extra_body" not in model_settings.kwargs + class TestOutputFormat: def test_absent_output_format_no_change(self) -> None: diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index ba8e6442..89b8bd87 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -5,6 +5,8 @@ from collections.abc import AsyncGenerator from typing import Any +from openai.resources.responses import AsyncResponses as _AsyncResponses + from launchdarkly_ai_server import ( AiConfigRep, LDContext, @@ -12,6 +14,7 @@ RunUsage, SpanMessage, SpanMessagePart, + accepted_parameter_keys_from_signature, compose_history, config, content_to_text, @@ -19,6 +22,7 @@ create_run_usage, end_span_once, end_unfinished_spans, + filter_forwardable_parameters, image_block_to_url, is_content_blocks, model_parameters, @@ -47,6 +51,46 @@ tool_arguments, ) +#: Accepted keys derived once from ``AsyncResponses.create``/``.stream``'s own signatures, never +#: hand-maintained. Neither has a ``**kwargs`` catch-all. Confirms the UI offers several keys the +#: Responses API has never accepted: ``max_tokens``, ``frequency_penalty``, ``presence_penalty``, +#: ``seed``, ``n``, ``stop``, ``response_format``, ``logit_bias``, ``logprobs``, +#: ``max_completion_tokens``, ``audio``, ``modalities``, ``prediction``. +_RESPONSES_CREATE_KEYS = accepted_parameter_keys_from_signature(_AsyncResponses.create) +_RESPONSES_STREAM_KEYS = accepted_parameter_keys_from_signature(_AsyncResponses.stream) + +#: Keys the Responses API signature accepts but that would break this handler if a config set +#: them, because the handler itself already decides that behaviour. Everything else the API +#: accepts is forwarded, including keys that are not strictly generation settings (``store``, +#: ``user``, ``safety_identifier``, ``prompt_cache_key``, ``prompt_cache_retention``, ``include``, +#: ``context_management``, ``metadata``, ``service_tier``, ``instructions``, ``moderation``, ...). +_RESPONSES_EXCLUDED_KEYS = frozenset( + { + "stream", # the handler chooses blocking vs. streaming itself, not via a kwarg + "stream_options", # only meaningful together with stream=True, which the handler controls + "background", # returns before the output exists, so the handler would get no result + "conversation", # server-side conversation state conflicts with the input the handler builds + "prompt", # server-side prompt template conflicts with the input the handler builds + } +) + + +def _apply_max_output_tokens_rename(params: dict[str, Any]) -> dict[str, Any]: + """Renames the Chat-Completions-era ``max_tokens``/``max_completion_tokens`` to the Responses + API's ``max_output_tokens``, the only one of the three the API actually accepts. + + Precedence when more than one is set: an explicit ``max_output_tokens`` wins outright, then + ``max_completion_tokens``, then ``max_tokens``. + """ + max_tokens = params.pop("max_tokens", None) + max_completion_tokens = params.pop("max_completion_tokens", None) + if "max_output_tokens" not in params: + if max_completion_tokens is not None: + params["max_output_tokens"] = max_completion_tokens + elif max_tokens is not None: + params["max_output_tokens"] = max_tokens + return params + def _build_tools(config_tools: dict[str, Any]) -> list[dict[str, Any]]: # Not filtered to the tools that have a registered handler, unlike the TypeScript SDK. That @@ -262,7 +306,11 @@ async def _call_impl( messages=root_messages, ) - extra_params = model_parameters(config) + extra_params = filter_forwardable_parameters( + _apply_max_output_tokens_rename(model_parameters(config)), + _RESPONSES_CREATE_KEYS, + _RESPONSES_EXCLUDED_KEYS, + ) for _owned_key in ( "model", "input", @@ -501,7 +549,11 @@ async def _stream_gen( tool_definitions=tool_definitions, ) - extra_params = model_parameters(config) + extra_params = filter_forwardable_parameters( + _apply_max_output_tokens_rename(model_parameters(config)), + _RESPONSES_STREAM_KEYS, + _RESPONSES_EXCLUDED_KEYS, + ) for _owned_key in ( "model", "input", diff --git a/packages/openai-messages/tests/test_handler.py b/packages/openai-messages/tests/test_handler.py index f9b169f5..c0902cad 100644 --- a/packages/openai-messages/tests/test_handler.py +++ b/packages/openai-messages/tests/test_handler.py @@ -12,6 +12,7 @@ from typing import Any, ClassVar from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest CONFIG = { @@ -1198,6 +1199,272 @@ async def test_streaming_forwards_snake_case_param( stream_kwargs = mock_openai.responses.stream.call_args.kwargs assert stream_kwargs["top_p"] == 0.3 + async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising( + self, mock_openai: MagicMock + ) -> None: + """None of these are ``responses.create`` parameters (they are Chat-Completions-era + keys); forwarding one unfiltered raises ``TypeError`` before this filter existed.""" + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": { + "max_tokens": 100, + "frequency_penalty": 0.1, + "presence_penalty": 0.1, + "seed": 42, + "n": 1, + "stop": ["END"], + "response_format": {"type": "text"}, + "logit_bias": {"50256": -100}, + "logprobs": True, + "max_completion_tokens": 50, + "audio": {"voice": "alloy"}, + "modalities": ["text"], + "prediction": {"type": "content", "content": "x"}, + "top_p": 0.5, + }, + }, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["top_p"] == 0.5 + for rejected in ( + "frequency_penalty", + "presence_penalty", + "seed", + "n", + "stop", + "response_format", + "logit_bias", + "logprobs", + "audio", + "modalities", + "prediction", + ): + assert rejected not in kwargs + + async def test_transport_key_is_never_forwarded( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": {"top_p": 0.5, "extra_body": {"secret": "value"}}, + }, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["top_p"] == 0.5 + assert "extra_body" not in kwargs + + async def test_stream_key_is_never_forwarded(self, mock_openai: MagicMock) -> None: + """The handler picks blocking vs. streaming by which client method it calls; a config + setting ``stream`` must not reach ``responses.create``, and other handler-controlled + transport keys (``background``, ``conversation``, ``prompt``, ``stream_options``) are + likewise dropped.""" + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": { + "stream": True, + "stream_options": {"include_usage": True}, + "background": True, + "conversation": "conv_123", + "prompt": {"id": "pmpt_123"}, + }, + }, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + for excluded in ( + "stream", + "stream_options", + "background", + "conversation", + "prompt", + ): + assert excluded not in kwargs + + async def test_store_and_other_non_generation_keys_are_forwarded( + self, mock_openai: MagicMock + ) -> None: + """Only keys that would break the handler are excluded; everything else the API accepts + is forwarded, even keys that are not strictly generation settings.""" + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": { + "store": False, + "user": "user-1", + "safety_identifier": "safe-1", + "prompt_cache_key": "cache-1", + "metadata": {"k": "v"}, + "service_tier": "auto", + "instructions": "be terse", + "moderation": "auto", + }, + }, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["store"] is False + assert kwargs["user"] == "user-1" + assert kwargs["safety_identifier"] == "safe-1" + assert kwargs["prompt_cache_key"] == "cache-1" + assert kwargs["metadata"] == {"k": "v"} + assert kwargs["service_tier"] == "auto" + assert kwargs["moderation"] == "auto" + # `instructions` is a plain accepted Responses API parameter here, not one this handler + # sets itself (it builds the system prompt into `input`, not a top-level `instructions` + # field), so a config value forwards through like any other accepted key. + assert kwargs["instructions"] == "be terse" + + +class TestMaxOutputTokensRename: + async def test_max_tokens_renamed_to_max_output_tokens( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"max_tokens": 111}}, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["max_output_tokens"] == 111 + assert "max_tokens" not in kwargs + + async def test_max_completion_tokens_renamed_to_max_output_tokens( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": {"max_completion_tokens": 222}, + }, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["max_output_tokens"] == 222 + assert "max_completion_tokens" not in kwargs + + async def test_explicit_max_output_tokens_wins_over_both( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": { + "max_output_tokens": 333, + "max_completion_tokens": 222, + "max_tokens": 111, + }, + }, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["max_output_tokens"] == 333 + + async def test_max_completion_tokens_wins_over_max_tokens_when_both_set( + self, mock_openai: MagicMock + ) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": {"max_completion_tokens": 222, "max_tokens": 111}, + }, + } + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert kwargs["max_output_tokens"] == 222 + + +class TestModelParametersReachTheWire: + """Intercepts the real outgoing HTTP request with an httpx MockTransport, rather than + asserting only on a mock of our own call, so this proves the renamed key actually leaves the + process on the wire the real ``openai`` client builds. + """ + + async def test_max_output_tokens_reaches_the_wire_for_a_config_max_tokens( + self, + ) -> None: + import openai + + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + captured: dict[str, Any] = {} + + def _handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "resp_1", + "object": "response", + "created_at": 1, + "status": "completed", + "model": "gpt-4o", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi"}], + } + ], + "usage": { + "input_tokens": 3, + "output_tokens": 2, + "total_tokens": 5, + }, + }, + ) + + transport = httpx.MockTransport(_handler) + real_client = openai.AsyncOpenAI( + api_key="test-key", http_client=httpx.AsyncClient(transport=transport) + ) + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": {"max_tokens": 256}}, + } + with patch("openai.AsyncOpenAI", return_value=real_client): + h = create_openai_messages_handler() + await h(config, "q", {}, {}) + + assert captured["body"]["max_output_tokens"] == 256 + assert "max_tokens" not in captured["body"] + class TestOutputFormat: async def test_absent_output_format_no_change(self, mock_openai: MagicMock) -> None: From 2e91885cbaa11e403e1f1dac1223fd944409f28e Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 14:25:25 -0400 Subject: [PATCH 03/26] refactor(client): replace runtime SDK introspection with explicit forwarded-key lists Every handler decided which model.parameters keys to forward by introspecting the live provider SDK at runtime: inspect.signature for the two Anthropic/OpenAI methods, dataclasses.fields for ClaudeAgentOptions/ModelSettings, pydantic model_fields plus aliases for the three LangChain chat models. Every competitor SDK (LiteLLM, Vercel AI SDK, Braintrust, LangChain) and this SDK's own TypeScript port use a written-down list instead, so replaces the derivation with one: each handler now carries a literal frozenset of the keys it forwards, generated once from the same introspection and pasted in as a reviewable list, next to its existing handler-owned and excluded sets. Deletes accepted_parameter_keys_from_signature/_from_dataclass/_from_pydantic_model, _alias_strings, is_transport_parameter, strip_transport_parameters, and filter_forwardable_parameters from packages/client's parameter_forwarding module now that nothing calls them. In their place, select_forwarded_parameters(params, keys) is the one shared piece left: keep the params keys that are on a handler's own forwarded list, drop everything else. Reproduces every handler's existing forwarded/owned/excluded classification exactly, with one deliberate addition applied consistently: client/connection configuration (API keys, base URLs, organization ids, HTTP clients, default headers/query, proxies, timeouts, retry counts) is now always excluded, even when a provider's own accept-set would otherwise let it through. This newly excludes, per handler: - langchain-messages / langchain-agents (ChatOpenAI): api_key, openai_api_key, base_url, openai_api_base, organization, openai_organization, openai_proxy, client, async_client, root_client, root_async_client, http_client, http_async_client, http_socket_options, default_headers, default_query, max_retries, request_timeout - langchain-messages / langchain-agents (ChatAnthropic): anthropic_api_key, api_key, anthropic_api_url, base_url, anthropic_proxy, default_request_timeout, max_retries, default_headers - langchain-messages / langchain-agents (ChatBedrockConverse, opt-in dependency): bedrock_api_key, api_key, aws_access_key_id, aws_secret_access_key, aws_session_token, credentials_profile_name, endpoint_url, base_url, client, bedrock_client, config, default_headers, max_retries - openai-agents (ModelSettings): retry timeout/extra_headers/extra_query/extra_body/extra_args were already excluded by the prior transport-prefix rule on every handler that has them; they are now named explicitly in each handler's own excluded set instead of matched by a shared prefix check, since nothing here is derived at runtime any more. Adds one drift test per handler package (test_parameter_forwarding.py) that reads the real provider SDK's signature/dataclass/model_fields and asserts every parameter it accepts is classified in exactly one of forwarded, handler-owned, or excluded, so an SDK addition nobody has classified fails loudly by name, and so does a stale list entry. The LangChain ChatBedrockConverse test skips itself when langchain-aws (an opt-in dependency) is not installed, matching this package's existing Bedrock test pattern. Updates langchain-messages' TestModelParametersReachTheWire test, which relied on forwarding http_async_client/api_key through model.parameters to mock the outgoing HTTP call: it now intercepts ChatOpenAI's own default httpx client builder instead, so the mock transport is reached without any config value ever naming an HTTP client or a key. Adds TestConnectionConfigIsNeverForwarded to both langchain-messages and langchain-agents, asserting api_key/base_url from model.parameters never reach the ChatOpenAI/ChatAnthropic constructor kwargs. Verified: uv run --frozen pytest (1385 passed, 15 skipped), uv run --frozen mypy packages/*/src, uv run --frozen ruff check ., uv run --frozen ruff format --check ., uv.lock unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../launchdarkly_ai_claude_agents/handler.py | 64 ++++- .../native_graph.py | 8 +- .../tests/test_parameter_forwarding.py | 54 ++++ .../handler.py | 75 +++-- .../tests/test_parameter_forwarding.py | 89 ++++++ .../src/launchdarkly_ai_server/__init__.py | 16 +- .../parameter_forwarding.py | 172 ++---------- .../client/tests/test_parameter_forwarding.py | 158 +---------- .../handler.py | 258 ++++++++++++++++-- .../native_graph.py | 8 +- .../langchain-agents/tests/test_handler.py | 40 +++ .../tests/test_parameter_forwarding.py | 139 ++++++++++ .../handler.py | 257 +++++++++++++++-- .../langchain-messages/tests/test_handler.py | 68 ++++- .../tests/test_parameter_forwarding.py | 139 ++++++++++ .../launchdarkly_ai_openai_agents/handler.py | 50 +++- .../native_graph.py | 8 +- .../tests/test_parameter_forwarding.py | 43 +++ .../handler.py | 101 +++++-- .../tests/test_parameter_forwarding.py | 88 ++++++ 20 files changed, 1396 insertions(+), 439 deletions(-) create mode 100644 packages/claude-agents/tests/test_parameter_forwarding.py create mode 100644 packages/claude-messages/tests/test_parameter_forwarding.py create mode 100644 packages/langchain-agents/tests/test_parameter_forwarding.py create mode 100644 packages/langchain-messages/tests/test_parameter_forwarding.py create mode 100644 packages/openai-agents/tests/test_parameter_forwarding.py create mode 100644 packages/openai-messages/tests/test_parameter_forwarding.py diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 13e1d02a..140120da 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -35,16 +35,15 @@ ProviderHandler, SpanMessage, SpanMessagePart, - accepted_parameter_keys_from_dataclass, compose_history, config, content_to_text, create_handler, end_span_once, end_unfinished_spans, - filter_forwardable_parameters, model_parameters, parse_template, + select_forwarded_parameters, set_conversation_id_if_absent, set_input_content_attributes, set_output_content_attributes, @@ -69,12 +68,63 @@ tool_display_name, ) -#: Accepted keys derived once from ``ClaudeAgentOptions``'s own fields, never hand-maintained. The -#: SDK offers no ``temperature``/``top_p``/``top_k``/``max_tokens``/``stop_sequences``/ +#: Every field ``ClaudeAgentOptions`` declares, classified by hand into exactly one of: forwarded +#: (below), handler-owned (``model``, ``allowed_tools``, ``mcp_servers``, ``hooks``, ``tools``, +#: ``system_prompt``, popped after the filter runs, at each call site), or excluded +#: (``extra_args``, a raw CLI-argument escape hatch, is client/connection configuration and is never +#: forwarded). ``TestClaudeAgentOptionsAcceptsExactlyTheseFields`` in this package's tests asserts +#: this classification stays exhaustive as the SDK's own dataclass changes. +#: +#: The SDK offers no ``temperature``/``top_p``/``top_k``/``max_tokens``/``stop_sequences``/ #: ``tool_choice``/``metadata``, all of which the LaunchDarkly UI's model parameters panel offers #: for other providers; forwarding one of those unfiltered raised ``TypeError`` before this filter #: existed. -_CLAUDE_AGENT_OPTIONS_KEYS = accepted_parameter_keys_from_dataclass(ClaudeAgentOptions) +_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS = frozenset( + { + "add_dirs", + "agents", + "betas", + "can_use_tool", + "cli_path", + "continue_conversation", + "cwd", + "debug_stderr", + "disallowed_tools", + "effort", + "enable_file_checkpointing", + "env", + "fallback_model", + "fork_session", + "include_hook_events", + "include_partial_messages", + "load_timeout_ms", + "max_budget_usd", + "max_buffer_size", + "max_thinking_tokens", + "max_turns", + "output_format", + "permission_mode", + "permission_prompt_tool_name", + "plugins", + "resume", + "sandbox", + "session_id", + "session_store", + "session_store_flush", + "setting_sources", + "settings", + "skills", + "stderr", + "strict_mcp_config", + "task_budget", + "thinking", + "user", + } +) + +#: Named for the drift test and for review, not read at runtime: the forwarded list above already +#: leaves this out, so nothing needs to subtract it again. +_CLAUDE_AGENT_OPTIONS_EXCLUDED_KEYS = frozenset({"extra_args"}) # --------------------------------------------------------------------------- # Tool wiring @@ -489,8 +539,8 @@ def _build_query_options( **extra: Any, ) -> ClaudeAgentOptions: all_allowed = [*mcp_allowed_tools, *native_tool_names] - params = filter_forwardable_parameters( - model_parameters(config), _CLAUDE_AGENT_OPTIONS_KEYS + params = select_forwarded_parameters( + model_parameters(config), _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS ) for _owned_key in ( "model", diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index 28f0d6a2..93f3baf7 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -16,10 +16,10 @@ GraphDefinition, GraphNode, NativeTool, - filter_forwardable_parameters, get_client, make_track_data, model_parameters, + select_forwarded_parameters, to_ld_context, ) @@ -32,7 +32,7 @@ _HAS_OTEL = False from launchdarkly_ai_claude_agents.handler import ( - _CLAUDE_AGENT_OPTIONS_KEYS, + _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS, _build_hooks, build_prompt, build_query_prompt, @@ -152,8 +152,8 @@ async def _run_query( hooks = _build_hooks(native_tool_map) - params = filter_forwardable_parameters( - model_parameters(node.config), _CLAUDE_AGENT_OPTIONS_KEYS + params = select_forwarded_parameters( + model_parameters(node.config), _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS ) for _owned_key in ( "model", diff --git a/packages/claude-agents/tests/test_parameter_forwarding.py b/packages/claude-agents/tests/test_parameter_forwarding.py new file mode 100644 index 00000000..3878c703 --- /dev/null +++ b/packages/claude-agents/tests/test_parameter_forwarding.py @@ -0,0 +1,54 @@ +""" +Drift test for the ``ClaudeAgentOptions`` parameter classification in ``handler.py``. + +``_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS`` is a literal, hand-maintained list. This test reads +``ClaudeAgentOptions``'s own dataclass fields and asserts every field it declares is classified in +exactly one of forwarded, handler-owned, or excluded, so an SDK field nobody has classified yet +fails loudly by name, and so does a list entry that is not a real SDK field. +""" + +from __future__ import annotations + +import dataclasses + +from claude_agent_sdk import ClaudeAgentOptions + +from launchdarkly_ai_claude_agents.handler import ( + _CLAUDE_AGENT_OPTIONS_EXCLUDED_KEYS as _EXCLUDED_KEYS, +) +from launchdarkly_ai_claude_agents.handler import _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS + +#: Handler-owned: popped from the filtered params before ``ClaudeAgentOptions(**kwargs)`` is +#: constructed, at every call site (``handler.py`` and ``native_graph.py``). +_OWNED_KEYS = frozenset( + {"model", "allowed_tools", "mcp_servers", "hooks", "tools", "system_prompt"} +) + + +class TestClaudeAgentOptionsAcceptsExactlyTheseFields: + def test_every_field_is_classified_exactly_once(self) -> None: + accepted = frozenset(f.name for f in dataclasses.fields(ClaudeAgentOptions)) + classified = _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS | _OWNED_KEYS | _EXCLUDED_KEYS + + unclassified = accepted - classified + assert not unclassified, ( + f"ClaudeAgentOptions now declares {sorted(unclassified)}, not classified as " + "forwarded, handler-owned, or excluded in claude-agents handler.py" + ) + + overlap = ( + (_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS & _OWNED_KEYS) + | (_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS & _EXCLUDED_KEYS) + | (_OWNED_KEYS & _EXCLUDED_KEYS) + ) + assert not overlap, f"fields classified more than once: {sorted(overlap)}" + + def test_every_classified_field_is_real(self) -> None: + accepted = frozenset(f.name for f in dataclasses.fields(ClaudeAgentOptions)) + stale = ( + _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS | _OWNED_KEYS | _EXCLUDED_KEYS + ) - accepted + assert not stale, ( + f"{sorted(stale)} classified in claude-agents handler.py but " + "ClaudeAgentOptions does not declare them" + ) diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index 4364aa70..fc03be58 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -11,17 +11,16 @@ ProviderHandler, SpanMessage, SpanMessagePart, - accepted_parameter_keys_from_signature, compose_history, config, content_to_text, create_handler, end_span_once, end_unfinished_spans, - filter_forwardable_parameters, is_content_blocks, model_parameters, parse_template, + select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, @@ -45,30 +44,52 @@ to_tool_definitions, ) -try: - import anthropic as _anthropic_mod - - _HAS_ANTHROPIC = True -except ImportError: - _HAS_ANTHROPIC = False - -#: Accepted keys derived once from ``AsyncMessages.create``/``.stream``'s own signatures, never -#: hand-maintained. Neither has a ``**kwargs`` catch-all. The two differ slightly (``stream`` also -#: takes ``output_format``), so each call site below filters against the method it actually calls. -_MESSAGES_CREATE_KEYS = accepted_parameter_keys_from_signature( - _anthropic_mod.resources.messages.AsyncMessages.create -) -_MESSAGES_STREAM_KEYS = accepted_parameter_keys_from_signature( - _anthropic_mod.resources.messages.AsyncMessages.stream +#: Every key ``AsyncMessages.create``/``.stream`` accept, classified by hand into exactly one of: +#: forwarded (below), handler-owned (``model``, ``messages``, ``system``, ``tools``, popped after +#: the filter runs, at each call site), or excluded (below). ``TestMessagesCreateAcceptsExactlyThese +#: Keys`` / ``TestMessagesStreamAcceptsExactlyTheseKeys`` in this package's tests assert this +#: classification stays exhaustive as the SDK's own signatures change. +#: +#: Everything else the API accepts is forwarded, including keys that are not strictly generation +#: settings (``metadata``, ``service_tier``, ``container``, ``user_profile_id``, ``output_config``, +#: ...). +_MESSAGES_CREATE_FORWARDED_KEYS = frozenset( + { + "cache_control", + "container", + "inference_geo", + "max_tokens", + "metadata", + "output_config", + "service_tier", + "stop_sequences", + "temperature", + "thinking", + "tool_choice", + "top_k", + "top_p", + "user_profile_id", + } ) -#: Keys the Messages API signature accepts but that would break this handler if a config set -#: them, because the handler itself already decides that behaviour. Everything else the API -#: accepts is forwarded, including keys that are not strictly generation settings (``metadata``, -#: ``service_tier``, ``container``, ``user_profile_id``, ``output_config``, ...). +#: Same as :data:`_MESSAGES_CREATE_FORWARDED_KEYS`, plus ``output_format``: ``.stream`` accepts it, +#: ``.create`` does not. +_MESSAGES_STREAM_FORWARDED_KEYS = _MESSAGES_CREATE_FORWARDED_KEYS | {"output_format"} + +#: Accepted by the API but never forwarded, and why: +#: * ``stream``: the handler chooses blocking vs. streaming itself, not via a kwarg. +#: * ``timeout``, ``extra_headers``, ``extra_query``, ``extra_body``: client/connection +#: configuration (a request timeout, raw HTTP overrides), never a config-controlled setting. +#: +#: Named for the drift test and for review, not read at runtime: the forwarded lists above already +#: leave these out, so nothing needs to subtract them again. _MESSAGES_EXCLUDED_KEYS = frozenset( { - "stream", # the handler chooses blocking vs. streaming itself, not via a kwarg + "stream", + "timeout", + "extra_headers", + "extra_query", + "extra_body", } ) @@ -247,10 +268,9 @@ async def _run_tool_loop( # difference predates this span work and changes what the model is offered, not what the span # reports, so it stays as it is: the catalog recorded below is the catalog actually sent. tools = _build_tools(config.get("tools") or {}) - extra_params = filter_forwardable_parameters( + extra_params = select_forwarded_parameters( _rename_effort_to_output_config(model_parameters(config)), - _MESSAGES_CREATE_KEYS, - _MESSAGES_EXCLUDED_KEYS, + _MESSAGES_CREATE_FORWARDED_KEYS, ) max_tokens = extra_params.pop("max_tokens", 1024) for _owned_key in ("model", "messages", "system", "tools"): @@ -546,10 +566,9 @@ async def _stream_gen( tools = _build_tools(config.get("tools") or {}) tool_definitions = to_tool_definitions(tools) - extra_params = filter_forwardable_parameters( + extra_params = select_forwarded_parameters( _rename_effort_to_output_config(model_parameters(config)), - _MESSAGES_STREAM_KEYS, - _MESSAGES_EXCLUDED_KEYS, + _MESSAGES_STREAM_FORWARDED_KEYS, ) max_tokens = extra_params.pop("max_tokens", 1024) for _owned_key in ("model", "messages", "system", "tools"): diff --git a/packages/claude-messages/tests/test_parameter_forwarding.py b/packages/claude-messages/tests/test_parameter_forwarding.py new file mode 100644 index 00000000..18f3c242 --- /dev/null +++ b/packages/claude-messages/tests/test_parameter_forwarding.py @@ -0,0 +1,89 @@ +""" +Drift test for the Anthropic Messages API parameter classification in ``handler.py``. + +``_MESSAGES_CREATE_FORWARDED_KEYS`` / ``_MESSAGES_STREAM_FORWARDED_KEYS`` are literal, +hand-maintained lists (see the module docstring there for why). This test is what keeps them +honest: it reads ``AsyncMessages.create``/``.stream``'s own signature and asserts every parameter +they accept is classified in exactly one of forwarded, handler-owned, or excluded, so an SDK +parameter nobody has classified yet fails loudly by name, and so does a list entry that is not a +real SDK parameter. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable + +import anthropic + +from launchdarkly_ai_claude_messages.handler import ( + _MESSAGES_CREATE_FORWARDED_KEYS, + _MESSAGES_EXCLUDED_KEYS, + _MESSAGES_STREAM_FORWARDED_KEYS, +) + +#: Handler-owned: always popped from the filtered params before the call, at both call sites. +_OWNED_KEYS = frozenset({"model", "messages", "system", "tools"}) + + +def _signature_keys(fn: Callable[..., object]) -> frozenset[str]: + keys: set[str] = set() + for name, param in inspect.signature(fn).parameters.items(): + if name == "self" or param.kind is inspect.Parameter.VAR_POSITIONAL: + continue + assert param.kind is not inspect.Parameter.VAR_KEYWORD, ( + f"{fn!r} now accepts **{name}; this test can no longer enumerate its accept-set" + ) + keys.add(name) + return keys + + +class TestMessagesCreateAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + accepted = _signature_keys(anthropic.resources.messages.AsyncMessages.create) + classified = ( + _MESSAGES_CREATE_FORWARDED_KEYS | _OWNED_KEYS | _MESSAGES_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"AsyncMessages.create now accepts {sorted(unclassified)}, not classified as " + "forwarded, handler-owned, or excluded in claude-messages handler.py" + ) + + overlap = ( + (_MESSAGES_CREATE_FORWARDED_KEYS & _OWNED_KEYS) + | (_MESSAGES_CREATE_FORWARDED_KEYS & _MESSAGES_EXCLUDED_KEYS) + | (_OWNED_KEYS & _MESSAGES_EXCLUDED_KEYS) + ) + assert not overlap, f"keys classified more than once: {sorted(overlap)}" + + def test_every_classified_key_is_a_real_parameter(self) -> None: + accepted = _signature_keys(anthropic.resources.messages.AsyncMessages.create) + stale = (_MESSAGES_CREATE_FORWARDED_KEYS | _OWNED_KEYS) - accepted + assert not stale, ( + f"{sorted(stale)} classified in claude-messages handler.py but " + "AsyncMessages.create does not accept them" + ) + + +class TestMessagesStreamAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + accepted = _signature_keys(anthropic.resources.messages.AsyncMessages.stream) + classified = ( + _MESSAGES_STREAM_FORWARDED_KEYS | _OWNED_KEYS | _MESSAGES_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"AsyncMessages.stream now accepts {sorted(unclassified)}, not classified as " + "forwarded, handler-owned, or excluded in claude-messages handler.py" + ) + + def test_every_classified_key_is_a_real_parameter(self) -> None: + accepted = _signature_keys(anthropic.resources.messages.AsyncMessages.stream) + stale = (_MESSAGES_STREAM_FORWARDED_KEYS | _OWNED_KEYS) - accepted + assert not stale, ( + f"{sorted(stale)} classified in claude-messages handler.py but " + "AsyncMessages.stream does not accept them" + ) diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index 02ceeb26..e1cd89cd 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -54,14 +54,7 @@ inspect_config, shutdown, ) -from .parameter_forwarding import ( - accepted_parameter_keys_from_dataclass, - accepted_parameter_keys_from_pydantic_model, - accepted_parameter_keys_from_signature, - filter_forwardable_parameters, - is_transport_parameter, - strip_transport_parameters, -) +from .parameter_forwarding import select_forwarded_parameters from .registry import ( Registry, compose, @@ -204,12 +197,7 @@ "Scorer", "init_evaluations", # parameter_forwarding - "accepted_parameter_keys_from_dataclass", - "accepted_parameter_keys_from_pydantic_model", - "accepted_parameter_keys_from_signature", - "filter_forwardable_parameters", - "is_transport_parameter", - "strip_transport_parameters", + "select_forwarded_parameters", # utils "create_handler", "make_track_data", diff --git a/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py b/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py index beee431f..0fc0b320 100644 --- a/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py +++ b/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py @@ -3,161 +3,35 @@ ``model.parameters`` is a free-form dict the LaunchDarkly UI writes, offering keys that make sense across providers (``temperature``, ``top_p``, ``max_tokens``, ``tool_choice``, ...). No -single provider SDK accepts all of them: passing a key a plain method or a dataclass constructor -does not declare raises ``TypeError`` before any request is made, and passing an unknown key to a -pydantic model is merely a landmine for the day a stricter model config removes that tolerance. - -Every handler therefore filters ``model_parameters(config)`` down to the keys the specific -provider entry point actually accepts before merging it into its own call kwargs. This module is -the one place that derives an accept-set from a live provider type (never a hand-maintained list, -which would drift the moment an SDK adds or removes a parameter) and the one place that strips -transport-only keys regardless of what the accept-set says. +single provider SDK accepts all of them, and each handler package classifies every key its own +provider entry point accepts into exactly one written-down list: forwarded, handler-owned (popped +after this filter runs, decided per call site), or excluded (accepted by the provider but never +forwarded, either because forwarding it would break the handler or because it is client/connection +configuration such as an API key, a base URL, an HTTP client, or a timeout). Those lists are +literal, next to each handler's own call sites, not derived from the provider SDK at runtime: a +hand-maintained list is reviewable and a runtime-derived one is not, and each handler package has a +drift test asserting its lists still cover everything its provider SDK accepts. + +This module is the one shared piece: given a params dict and the literal forwarded-keys list, keep +only the keys on that list. """ from __future__ import annotations -import dataclasses -import inspect -from collections.abc import Callable, Mapping +from collections.abc import Mapping from typing import Any -#: Keys that are never forwarded to a provider call, even when the callable's own signature -#: happens to accept them. These are transport or escape-hatch concerns (request timeouts, raw -#: HTTP overrides), not model settings, and forwarding one from ``model.parameters`` would let a -#: config silently rewrite the transport for every call under it. -_TRANSPORT_PARAMETER_KEYS = frozenset({"timeout"}) -_TRANSPORT_PARAMETER_PREFIX = "extra_" - - -def is_transport_parameter(key: str) -> bool: - """Whether *key* is a transport/escape-hatch parameter that must never be forwarded. - - Matches the exact names ``timeout`` and any key prefixed with ``extra_`` (``extra_headers``, - ``extra_query``, ``extra_body``, ``extra_args``, and any future ``extra_*`` addition). - """ - return key in _TRANSPORT_PARAMETER_KEYS or key.startswith( - _TRANSPORT_PARAMETER_PREFIX - ) - - -def strip_transport_parameters(params: Mapping[str, Any]) -> dict[str, Any]: - """Returns a copy of *params* with every transport/escape-hatch key removed. - - Always applied before the accept-set filter below, so a provider call signature that happens - to declare ``timeout`` or ``extra_body`` can never let a config value reach it. - """ - return {k: v for k, v in params.items() if not is_transport_parameter(k)} - - -def accepted_parameter_keys_from_signature(fn: Callable[..., Any]) -> frozenset[str]: - """Derives the accepted keyword-argument names from a plain callable's signature. - - For the provider methods this SDK forwards to directly (``AsyncAnthropic.messages.create``, - ``AsyncOpenAI.responses.create``, and their streaming counterparts), the accepted keys are - exactly the callable's parameter names, read once via :func:`inspect.signature` rather than - hand-maintained, so a parameter the SDK adds or removes is picked up automatically. - - Raises ``ValueError`` if the signature has a ``**kwargs`` catch-all: that shape has no - derivable accept-set, and treating it as "accepts everything" would defeat the point of this - module. Callers should surface which callable that was rather than silently letting everything - through. - """ - signature = inspect.signature(fn) - accepted: set[str] = set() - for name, param in signature.parameters.items(): - if name == "self": - continue - if param.kind is inspect.Parameter.VAR_KEYWORD: - raise ValueError( - f"{fn!r} accepts **{name}; no accept-set can be derived from its signature" - ) - if param.kind is inspect.Parameter.VAR_POSITIONAL: - continue - accepted.add(name) - return frozenset(accepted) - -def accepted_parameter_keys_from_dataclass(cls: type) -> frozenset[str]: - """Derives the accepted keys from a dataclass's field names. - - Used for ``ClaudeAgentOptions`` (claude-agents) and ``ModelSettings`` (openai-agents), both of - which are plain dataclasses with no ``**kwargs`` catch-all. - """ - return frozenset(f.name for f in dataclasses.fields(cls)) - - -def _alias_strings(field: Any) -> list[str]: - """Every string alias a pydantic ``FieldInfo``-like object declares. - - Duck-typed against ``.alias`` and ``.validation_alias`` rather than importing pydantic, so a - lightweight test double built the same shape works identically to a real ``FieldInfo``. - ``validation_alias`` may itself be a plain string, or an ``AliasChoices``/``AliasPath`` whose - ``.choices`` holds the individual alias strings; anything else is ignored. - """ - aliases: list[str] = [] - alias = getattr(field, "alias", None) - if isinstance(alias, str): - aliases.append(alias) - validation_alias = getattr(field, "validation_alias", None) - if isinstance(validation_alias, str): - aliases.append(validation_alias) - else: - choices = getattr(validation_alias, "choices", None) - if choices: - aliases.extend(choice for choice in choices if isinstance(choice, str)) - return aliases - - -def accepted_parameter_keys_from_pydantic_model(cls: Any) -> frozenset[str]: - """Derives the accepted keys from a pydantic model's fields and their aliases. - - Used for the LangChain chat model classes (``ChatOpenAI``, ``ChatAnthropic``, - ``ChatBedrockConverse``), which populate by alias (``populate_by_name=True``, - ``validate_by_alias=True``): a constructor kwarg may name either the field itself or one of - its aliases, so both must be in the accept-set for a value to actually land. - - Reads ``cls.model_fields`` (pydantic v2) rather than importing pydantic, so this also accepts - any test double exposing the same shape. Raises ``ValueError`` if *cls* has no usable - ``model_fields`` mapping. - """ - fields = getattr(cls, "model_fields", None) - if not isinstance(fields, Mapping): - raise ValueError( - f"{cls!r} has no usable model_fields; cannot derive an accept-set from it" - ) - accepted: set[str] = set() - for name, field in fields.items(): - accepted.add(name) - accepted.update(_alias_strings(field)) - return frozenset(accepted) - - -def filter_forwardable_parameters( - params: Mapping[str, Any], - accepted_keys: frozenset[str], - excluded_keys: frozenset[str] = frozenset(), +def select_forwarded_parameters( + params: Mapping[str, Any], forwarded_keys: frozenset[str] ) -> dict[str, Any]: - """Returns the subset of *params* that is safe to forward to a provider call. - - Three passes, in order: transport/escape-hatch keys are stripped unconditionally (see - :func:`strip_transport_parameters`), then *excluded_keys* are stripped, then whatever remains is - filtered down to *accepted_keys*. - - *excluded_keys* is for a key the provider's own signature accepts but that would break the - handler if a config set it, because the handler itself decides that behaviour (for example - ``stream``, which the handler picks by calling a blocking or a streaming method, not by a - kwarg). This is a narrower, opt-in exclusion than the handler-owned keys a call site pops after - this filter runs (``model``, ``messages``/``input``, ``tools``, ...): those are always removed, - this is provider-specific and each call site decides its own set. The rule for both this and - the handler-owned set is the same: exclude only what would break the handler, forward - everything else the provider accepts, even keys that are not strictly generation settings. - A key the UI offers but the specific provider entry point does not declare is dropped rather - than raising, matching this SDK's behaviour before ``model.parameters`` forwarding existed: - an unrecognised tuning value is silently ignored, not a hard failure. + """Returns the subset of *params* whose key is in *forwarded_keys*. + + *forwarded_keys* is a handler's literal, hand-maintained list of the keys it forwards to its + provider call: never the provider's full accept-set, since a handler's own owned and excluded + keys (including client/connection configuration) are already left off that list. A key present + in *params* but not in *forwarded_keys* is silently dropped rather than raising, matching this + SDK's behaviour before ``model.parameters`` forwarding existed: an unrecognised tuning value is + ignored, not a hard failure. """ - transport_free = strip_transport_parameters(params) - return { - k: v - for k, v in transport_free.items() - if k not in excluded_keys and k in accepted_keys - } + return {k: v for k, v in params.items() if k in forwarded_keys} diff --git a/packages/client/tests/test_parameter_forwarding.py b/packages/client/tests/test_parameter_forwarding.py index 1e6529ad..3f412ef5 100644 --- a/packages/client/tests/test_parameter_forwarding.py +++ b/packages/client/tests/test_parameter_forwarding.py @@ -1,159 +1,29 @@ -""" -Tests for the shared model.parameters filter: is_transport_parameter, -strip_transport_parameters, accepted_parameter_keys_from_signature, -accepted_parameter_keys_from_dataclass, accepted_parameter_keys_from_pydantic_model, -filter_forwardable_parameters. -""" +"""Tests for the shared model.parameters filter: select_forwarded_parameters.""" from __future__ import annotations -from dataclasses import dataclass -from types import SimpleNamespace -from typing import Any +from launchdarkly_ai_server import select_forwarded_parameters -import pytest -from launchdarkly_ai_server import ( - accepted_parameter_keys_from_dataclass, - accepted_parameter_keys_from_pydantic_model, - accepted_parameter_keys_from_signature, - filter_forwardable_parameters, - is_transport_parameter, - strip_transport_parameters, -) - - -class TestIsTransportParameter: - @pytest.mark.parametrize( - "key", - [ - "timeout", - "extra_headers", - "extra_query", - "extra_body", - "extra_args", - "extra_x", - ], - ) - def test_matches_known_transport_keys(self, key: str) -> None: - assert is_transport_parameter(key) - - @pytest.mark.parametrize("key", ["temperature", "top_p", "model", "extraordinary"]) - def test_does_not_match_generation_keys(self, key: str) -> None: - assert not is_transport_parameter(key) - - -class TestStripTransportParameters: - def test_removes_every_transport_key(self) -> None: - params = { - "temperature": 0.5, - "timeout": 30, - "extra_body": {"a": 1}, - "extra_headers": {"h": "v"}, - } - assert strip_transport_parameters(params) == {"temperature": 0.5} - - def test_does_not_mutate_input(self) -> None: - params = {"timeout": 30, "temperature": 0.5} - strip_transport_parameters(params) - assert params == {"timeout": 30, "temperature": 0.5} - - def test_empty_input_returns_empty(self) -> None: - assert strip_transport_parameters({}) == {} - - -class TestAcceptedParameterKeysFromSignature: - def test_derives_names_from_plain_function(self) -> None: - def fn(a: int, b: str = "x") -> None: ... - - assert accepted_parameter_keys_from_signature(fn) == frozenset({"a", "b"}) - - def test_excludes_self(self) -> None: - class C: - def method(self, a: int) -> None: ... - - assert accepted_parameter_keys_from_signature(C.method) == frozenset({"a"}) - - def test_excludes_var_positional(self) -> None: - def fn(a: int, *args: Any) -> None: ... - - assert accepted_parameter_keys_from_signature(fn) == frozenset({"a"}) - - def test_raises_on_var_keyword_catch_all(self) -> None: - def fn(a: int, **kwargs: Any) -> None: ... - - with pytest.raises(ValueError, match="kwargs"): - accepted_parameter_keys_from_signature(fn) - - -class TestAcceptedParameterKeysFromDataclass: - def test_derives_field_names(self) -> None: - @dataclass - class Options: - a: int = 0 - b: str = "" - - assert accepted_parameter_keys_from_dataclass(Options) == frozenset({"a", "b"}) - - -class TestAcceptedParameterKeysFromPydanticModel: - def test_includes_field_names_and_string_aliases(self) -> None: - cls = SimpleNamespace( - model_fields={ - "model_name": SimpleNamespace(alias="model", validation_alias="model"), - "temperature": SimpleNamespace(alias=None, validation_alias=None), - } - ) - keys = accepted_parameter_keys_from_pydantic_model(cls) - assert keys == frozenset({"model_name", "model", "temperature"}) - - def test_reads_alias_choices(self) -> None: - cls = SimpleNamespace( - model_fields={ - "field_a": SimpleNamespace( - alias=None, - validation_alias=SimpleNamespace( - choices=["alias_one", "alias_two"] - ), - ), - } - ) - keys = accepted_parameter_keys_from_pydantic_model(cls) - assert keys == frozenset({"field_a", "alias_one", "alias_two"}) - - def test_raises_when_model_fields_missing(self) -> None: - with pytest.raises(ValueError, match="model_fields"): - accepted_parameter_keys_from_pydantic_model(SimpleNamespace()) - - def test_raises_when_model_fields_not_a_mapping(self) -> None: - with pytest.raises(ValueError, match="model_fields"): - accepted_parameter_keys_from_pydantic_model( - SimpleNamespace(model_fields="not-a-mapping") - ) - - -class TestFilterForwardableParameters: - def test_keeps_only_accepted_keys(self) -> None: +class TestSelectForwardedParameters: + def test_keeps_only_forwarded_keys(self) -> None: params = {"temperature": 0.5, "unknown": 1} - result = filter_forwardable_parameters(params, frozenset({"temperature"})) + result = select_forwarded_parameters(params, frozenset({"temperature"})) assert result == {"temperature": 0.5} - def test_strips_transport_keys_even_if_accepted(self) -> None: - params = {"temperature": 0.5, "timeout": 30, "extra_body": {"a": 1}} - accepted = frozenset({"temperature", "timeout", "extra_body"}) - result = filter_forwardable_parameters(params, accepted) - assert result == {"temperature": 0.5} - - def test_strips_excluded_keys_even_if_accepted(self) -> None: - params = {"temperature": 0.5, "stream": True} - accepted = frozenset({"temperature", "stream"}) - result = filter_forwardable_parameters(params, accepted, frozenset({"stream"})) + def test_drops_a_key_the_handler_never_classified_as_forwarded(self) -> None: + params = {"temperature": 0.5, "api_key": "secret"} + result = select_forwarded_parameters(params, frozenset({"temperature"})) assert result == {"temperature": 0.5} def test_empty_params_returns_empty(self) -> None: - assert filter_forwardable_parameters({}, frozenset({"temperature"})) == {} + assert select_forwarded_parameters({}, frozenset({"temperature"})) == {} + + def test_empty_forwarded_keys_returns_empty(self) -> None: + params = {"temperature": 0.5} + assert select_forwarded_parameters(params, frozenset()) == {} def test_does_not_mutate_input(self) -> None: params = {"temperature": 0.5, "unknown": 1} - filter_forwardable_parameters(params, frozenset({"temperature"})) + select_forwarded_parameters(params, frozenset({"temperature"})) assert params == {"temperature": 0.5, "unknown": 1} 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 649b99e7..1dade9d0 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -6,7 +6,6 @@ from __future__ import annotations import asyncio -import functools import json from collections.abc import AsyncGenerator from typing import Any @@ -17,19 +16,18 @@ ProviderHandler, SpanMessage, SpanMessagePart, - accepted_parameter_keys_from_pydantic_model, compose_history, config, create_handler, create_run_usage, end_span_once, end_unfinished_spans, - filter_forwardable_parameters, lang_chain_content_text, lang_chain_span_messages, lang_chain_span_usage, model_parameters, parse_template, + select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, ) @@ -46,16 +44,236 @@ to_tool_definitions, ) +#: Every key ``ChatOpenAI`` accepts (field names plus pydantic aliases), classified by hand into +#: exactly one of: forwarded (below), handler-owned (``model``, always overwritten by the +#: resolved model name, see ``_model_constructor_kwargs``), or excluded. ``TestChatOpenAIAccepts +#: ExactlyTheseKeys`` in this package's tests asserts this classification stays exhaustive as the +#: SDK's own pydantic model changes. +#: +#: Excluded, and why: all client/connection configuration, never a config-controlled setting. +#: * ``api_key``, ``openai_api_key``: the API key. +#: * ``base_url``, ``openai_api_base``: the API base URL. +#: * ``organization``, ``openai_organization``: the organization id used for auth. +#: * ``openai_proxy``: an HTTP proxy. +#: * ``client``, ``async_client``, ``root_client``, ``root_async_client``, ``http_client``, +#: ``http_async_client``, ``http_socket_options``: raw HTTP client objects/settings. +#: * ``default_headers``, ``default_query``: default request headers/query params. +#: * ``max_retries``: an HTTP retry count. +#: * ``request_timeout``, ``timeout``: request timeouts. +#: * ``extra_body``: a raw request-body override. +#: +#: Everything else the model accepts is forwarded, including keys that are not strictly generation +#: settings (``store``, ``stream_usage``, ``tags``, ``metadata``, ``model_kwargs``, ...). +_CHAT_OPENAI_FORWARDED_KEYS = frozenset( + { + "cache", + "callbacks", + "context_management", + "custom_get_token_ids", + "disable_streaming", + "disabled_params", + "frequency_penalty", + "include", + "include_response_headers", + "logit_bias", + "logprobs", + "max_completion_tokens", + "max_tokens", + "metadata", + "model_kwargs", + "model_name", + "n", + "name", + "output_version", + "presence_penalty", + "profile", + "rate_limiter", + "reasoning", + "reasoning_effort", + "seed", + "service_tier", + "stop", + "stop_sequences", + "store", + "stream_chunk_timeout", + "stream_usage", + "streaming", + "tags", + "temperature", + "tiktoken_model_name", + "top_logprobs", + "top_p", + "truncation", + "use_previous_response_id", + "use_responses_api", + "verbose", + "verbosity", + } +) -@functools.cache -def pydantic_accept_keys(model_cls: Any) -> frozenset[str]: - """Caches the accept-set for a LangChain chat model class, keyed by the class object itself. +#: Named for the drift test and for review, not read at runtime: the forwarded list above already +#: leaves these out, so nothing needs to subtract them again. +_CHAT_OPENAI_EXCLUDED_KEYS = frozenset( + { + "api_key", + "openai_api_key", + "base_url", + "openai_api_base", + "organization", + "openai_organization", + "openai_proxy", + "client", + "async_client", + "root_client", + "root_async_client", + "http_client", + "http_async_client", + "http_socket_options", + "default_headers", + "default_query", + "max_retries", + "request_timeout", + "timeout", + "extra_body", + } +) - Computed once per class rather than per call: ``ChatOpenAI``/``ChatAnthropic``/ - ``ChatBedrockConverse`` field introspection is cheap but there is no reason to repeat it on - every invocation of a hot path. - """ - return accepted_parameter_keys_from_pydantic_model(model_cls) +#: Every key ``ChatAnthropic`` accepts (field names plus pydantic aliases), classified the same way +#: as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``TestChatAnthropicAcceptsExactlyTheseKeys`` in this +#: package's tests asserts this classification stays exhaustive. +#: +#: Excluded, and why: all client/connection configuration: +#: * ``anthropic_api_key``, ``api_key``: the API key (field plus alias). +#: * ``anthropic_api_url``, ``base_url``: the API base URL (field plus alias). +#: * ``anthropic_proxy``: an HTTP proxy. +#: * ``default_request_timeout``, ``timeout``: a request timeout (field plus alias). +#: * ``max_retries``: an HTTP retry count. +#: * ``default_headers``: default request headers. +_CHAT_ANTHROPIC_FORWARDED_KEYS = frozenset( + { + "betas", + "cache", + "callbacks", + "context_management", + "custom_get_token_ids", + "disable_streaming", + "effort", + "inference_geo", + "max_tokens", + "max_tokens_to_sample", + "mcp_servers", + "metadata", + "model_kwargs", + "model_name", + "name", + "output_config", + "output_version", + "profile", + "rate_limiter", + "reuse_last_container", + "stop", + "stop_sequences", + "stream_usage", + "streaming", + "tags", + "temperature", + "thinking", + "top_k", + "top_p", + "verbose", + } +) + +#: Named for the drift test and for review, not read at runtime. +_CHAT_ANTHROPIC_EXCLUDED_KEYS = frozenset( + { + "anthropic_api_key", + "api_key", + "anthropic_api_url", + "base_url", + "anthropic_proxy", + "default_request_timeout", + "timeout", + "max_retries", + "default_headers", + } +) + +#: Every key ``ChatBedrockConverse`` accepts (field names plus pydantic aliases), classified the +#: same way as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``langchain-aws`` is not a dependency of this +#: package (Bedrock support is opt-in, see ``_make_default_chat_model``), so +#: ``TestChatBedrockConverseAcceptsExactlyTheseKeys`` in this package's tests skips itself when it +#: is not installed rather than asserting nothing. +#: +#: Excluded, and why: all client/connection configuration: +#: * ``bedrock_api_key``, ``api_key``: the API key (field plus alias). +#: * ``aws_access_key_id``, ``aws_secret_access_key``, ``aws_session_token``: AWS credentials. +#: * ``credentials_profile_name``: the AWS credentials profile used for auth. +#: * ``endpoint_url``, ``base_url``: the API base URL (field plus alias). +#: * ``client``, ``bedrock_client``: raw HTTP/boto3 client objects. +#: * ``config``: a raw botocore client config object. +#: * ``default_headers``: default request headers. +#: * ``max_retries``: an HTTP retry count. +#: * ``timeout``: a request timeout. +_CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = frozenset( + { + "additional_model_request_fields", + "additional_model_response_field_paths", + "base_model", + "base_model_id", + "cache", + "callbacks", + "custom_get_token_ids", + "disable_streaming", + "guard_last_turn_only", + "guardrail_config", + "guardrails", + "max_tokens", + "metadata", + "model_id", + "name", + "output_config", + "output_version", + "performance_config", + "profile", + "provider", + "rate_limiter", + "raw_blocks", + "reasoning_effort", + "region_name", + "request_metadata", + "service_tier", + "stop", + "stop_sequences", + "streaming", + "supports_tool_choice_values", + "system", + "tags", + "temperature", + "top_p", + "verbose", + } +) + +#: Named for the drift test and for review, not read at runtime. +_CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS = frozenset( + { + "bedrock_api_key", + "api_key", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "credentials_profile_name", + "endpoint_url", + "base_url", + "client", + "bedrock_client", + "config", + "default_headers", + "max_retries", + "timeout", + } +) def _build_agent_tools( @@ -185,16 +403,16 @@ def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: def _model_constructor_kwargs( - config: AiConfigRep, fallback_name: str, model_cls: Any = None + config: AiConfigRep, + fallback_name: str, + forwarded_keys: frozenset[str] | None = None, ) -> dict[str, Any]: parameters = model_parameters(config) provider = str((config.get("provider") or {}).get("name") or "").lower() if provider == "bedrock": parameters.pop("tools", None) - if model_cls is not None and parameters: - parameters = filter_forwardable_parameters( - parameters, pydantic_accept_keys(model_cls) - ) + if forwarded_keys is not None and parameters: + parameters = select_forwarded_parameters(parameters, forwarded_keys) parameters["model"] = _resolved_model_name(config, fallback_name) return parameters @@ -218,7 +436,7 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: lc_anthropic = importlib.import_module("langchain_anthropic") return lc_anthropic.ChatAnthropic( **_model_constructor_kwargs( - config, "claude-3-5-sonnet-20241022", lc_anthropic.ChatAnthropic + config, "claude-3-5-sonnet-20241022", _CHAT_ANTHROPIC_FORWARDED_KEYS ) ) if provider == "bedrock": @@ -230,11 +448,13 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: "Install it with: pip install langchain-aws" ) from exc return lc_aws.ChatBedrockConverse( - **_model_constructor_kwargs(config, "", lc_aws.ChatBedrockConverse) + **_model_constructor_kwargs( + config, "", _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS + ) ) lc_openai = importlib.import_module("langchain_openai") return lc_openai.ChatOpenAI( - **_model_constructor_kwargs(config, "gpt-4o", lc_openai.ChatOpenAI) + **_model_constructor_kwargs(config, "gpt-4o", _CHAT_OPENAI_FORWARDED_KEYS) ) diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index b2ab320f..1f5f9323 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -16,15 +16,15 @@ GraphNode, NativeTool, compose_history, - filter_forwardable_parameters, get_client, make_track_data, model_parameters, parse_template, + select_forwarded_parameters, to_ld_context, ) -from .handler import pydantic_accept_keys +from .handler import _CHAT_OPENAI_FORWARDED_KEYS from .messages import to_lang_chain_messages try: @@ -206,8 +206,8 @@ async def _traverse_node(node: GraphNode) -> None: model_cfg = node.config.get("model") or {} kwargs = model_parameters(node.config) if kwargs: - kwargs = filter_forwardable_parameters( - kwargs, pydantic_accept_keys(lc_openai.ChatOpenAI) + kwargs = select_forwarded_parameters( + kwargs, _CHAT_OPENAI_FORWARDED_KEYS ) kwargs["model"] = model_cfg.get("name") or "gpt-4o" chat_model = lc_openai.ChatOpenAI(**kwargs) diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index 4f0d45a2..095ed5d4 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -2480,3 +2480,43 @@ def factory(config: Any) -> Any: assert any( e.get("type") == "chunk" and e.get("text") == "streamed" for e in events ) + + +class TestConnectionConfigIsNeverForwarded: + """``api_key``/``base_url`` in ``model.parameters`` are client/connection configuration; a + config author must never be able to redirect a call to a different endpoint or credential. + """ + + @pytest.mark.parametrize( + "provider,fallback", [("openai", "gpt-4o"), ("anthropic", "claude")] + ) + def test_api_key_and_base_url_are_never_forwarded( + self, provider: str, fallback: str + ) -> None: + from launchdarkly_ai_langchain_agents.handler import ( + _CHAT_ANTHROPIC_FORWARDED_KEYS, + _CHAT_OPENAI_FORWARDED_KEYS, + _model_constructor_kwargs, + ) + + forwarded_keys = ( + _CHAT_OPENAI_FORWARDED_KEYS + if provider == "openai" + else _CHAT_ANTHROPIC_FORWARDED_KEYS + ) + cfg = { + **BASE_CONFIG, + "provider": {"name": provider}, + "model": { + "name": fallback, + "parameters": { + "api_key": "stolen-key", + "base_url": "https://evil.example.com", + "temperature": 0.3, + }, + }, + } + kwargs = _model_constructor_kwargs(cfg, fallback, forwarded_keys) + assert "api_key" not in kwargs + assert "base_url" not in kwargs + assert kwargs["temperature"] == 0.3 diff --git a/packages/langchain-agents/tests/test_parameter_forwarding.py b/packages/langchain-agents/tests/test_parameter_forwarding.py new file mode 100644 index 00000000..41f4a249 --- /dev/null +++ b/packages/langchain-agents/tests/test_parameter_forwarding.py @@ -0,0 +1,139 @@ +""" +Drift test for the LangChain chat model parameter classification in ``handler.py``. + +``_CHAT_OPENAI_FORWARDED_KEYS`` / ``_CHAT_ANTHROPIC_FORWARDED_KEYS`` / +``_CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS`` are literal, hand-maintained lists. This test reads each +chat model's own ``model_fields`` (field names plus pydantic aliases) and asserts every key it +accepts is classified in exactly one of forwarded, handler-owned, or excluded, so a field nobody has +classified yet fails loudly by name, and so does a list entry that is not a real field. + +``langchain-aws`` is not a dependency of this package (Bedrock support is opt-in), so the +``ChatBedrockConverse`` test skips itself when it is not installed. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import langchain_anthropic +import langchain_openai +import pytest + +from launchdarkly_ai_langchain_agents.handler import ( + _CHAT_ANTHROPIC_EXCLUDED_KEYS, + _CHAT_ANTHROPIC_FORWARDED_KEYS, + _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS, + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, + _CHAT_OPENAI_EXCLUDED_KEYS, + _CHAT_OPENAI_FORWARDED_KEYS, +) + +#: Handler-owned on every model: always overwritten by the resolved model name after the filter +#: runs, see ``_model_constructor_kwargs``. +_OWNED_KEYS = frozenset({"model"}) + + +def _accepted_keys(cls: Any) -> frozenset[str]: + """Every key *cls* (a pydantic model) accepts by construction: each field's own name plus any + string alias it declares.""" + fields: Mapping[str, Any] = cls.model_fields + accepted: set[str] = set() + for name, field in fields.items(): + accepted.add(name) + alias = getattr(field, "alias", None) + if isinstance(alias, str): + accepted.add(alias) + validation_alias = getattr(field, "validation_alias", None) + if isinstance(validation_alias, str): + accepted.add(validation_alias) + else: + choices = getattr(validation_alias, "choices", None) + if choices: + accepted.update(c for c in choices if isinstance(c, str)) + return frozenset(accepted) + + +class TestChatOpenAIAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + accepted = _accepted_keys(langchain_openai.ChatOpenAI) + classified = ( + _CHAT_OPENAI_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_OPENAI_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"ChatOpenAI now accepts {sorted(unclassified)}, not classified as forwarded, " + "handler-owned, or excluded in langchain-agents handler.py" + ) + + overlap = ( + (_CHAT_OPENAI_FORWARDED_KEYS & _OWNED_KEYS) + | (_CHAT_OPENAI_FORWARDED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) + | (_OWNED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) + ) + assert not overlap, f"keys classified more than once: {sorted(overlap)}" + + def test_every_classified_key_is_real(self) -> None: + accepted = _accepted_keys(langchain_openai.ChatOpenAI) + stale = ( + _CHAT_OPENAI_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_OPENAI_EXCLUDED_KEYS + ) - accepted + assert not stale, ( + f"{sorted(stale)} classified in langchain-agents handler.py but " + "ChatOpenAI does not accept them" + ) + + +class TestChatAnthropicAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + accepted = _accepted_keys(langchain_anthropic.ChatAnthropic) + classified = ( + _CHAT_ANTHROPIC_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_ANTHROPIC_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"ChatAnthropic now accepts {sorted(unclassified)}, not classified as forwarded, " + "handler-owned, or excluded in langchain-agents handler.py" + ) + + def test_every_classified_key_is_real(self) -> None: + accepted = _accepted_keys(langchain_anthropic.ChatAnthropic) + stale = ( + _CHAT_ANTHROPIC_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_ANTHROPIC_EXCLUDED_KEYS + ) - accepted + assert not stale, ( + f"{sorted(stale)} classified in langchain-agents handler.py but " + "ChatAnthropic does not accept them" + ) + + +class TestChatBedrockConverseAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + lc_aws = pytest.importorskip("langchain_aws") + accepted = _accepted_keys(lc_aws.ChatBedrockConverse) + classified = ( + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS + | _OWNED_KEYS + | _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"ChatBedrockConverse now accepts {sorted(unclassified)}, not classified as " + "forwarded, handler-owned, or excluded in langchain-agents handler.py" + ) + + def test_every_classified_key_is_real(self) -> None: + lc_aws = pytest.importorskip("langchain_aws") + accepted = _accepted_keys(lc_aws.ChatBedrockConverse) + stale = ( + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS + | _OWNED_KEYS + | _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS + ) - accepted + assert not stale, ( + f"{sorted(stale)} classified in langchain-agents handler.py but " + "ChatBedrockConverse does not accept them" + ) 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 6c5a761c..2e025634 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import functools import json from collections.abc import AsyncGenerator from types import SimpleNamespace @@ -14,14 +13,12 @@ SpanMessage, SpanMessagePart, SpanUsage, - accepted_parameter_keys_from_pydantic_model, compose_history, config, create_handler, create_run_usage, end_span_once, end_unfinished_spans, - filter_forwardable_parameters, image_block_to_url, is_content_blocks, lang_chain_content_text, @@ -31,6 +28,7 @@ model_parameters, number_or_zero, parse_template, + select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, @@ -49,15 +47,236 @@ to_tool_definitions, ) +#: Every key ``ChatOpenAI`` accepts (field names plus pydantic aliases), classified by hand into +#: exactly one of: forwarded (below), handler-owned (``model``: always overwritten by the +#: resolved model name, see ``_model_constructor_kwargs``), or excluded. ``TestChatOpenAIAccepts +#: ExactlyTheseKeys`` in this package's tests asserts this classification stays exhaustive as the +#: SDK's own pydantic model changes. +#: +#: Excluded, and why: all client/connection configuration, never a config-controlled setting: +#: * ``api_key``, ``openai_api_key``: the API key. +#: * ``base_url``, ``openai_api_base``: the API base URL. +#: * ``organization``, ``openai_organization``: the organization id used for auth. +#: * ``openai_proxy``: an HTTP proxy. +#: * ``client``, ``async_client``, ``root_client``, ``root_async_client``, ``http_client``, +#: ``http_async_client``, ``http_socket_options``: raw HTTP client objects/settings. +#: * ``default_headers``, ``default_query``: default request headers/query params. +#: * ``max_retries``: an HTTP retry count. +#: * ``request_timeout``, ``timeout``: request timeouts. +#: * ``extra_body``: a raw request-body override. +#: +#: Everything else the model accepts is forwarded, including keys that are not strictly generation +#: settings (``store``, ``stream_usage``, ``tags``, ``metadata``, ``model_kwargs``, ...). +_CHAT_OPENAI_FORWARDED_KEYS = frozenset( + { + "cache", + "callbacks", + "context_management", + "custom_get_token_ids", + "disable_streaming", + "disabled_params", + "frequency_penalty", + "include", + "include_response_headers", + "logit_bias", + "logprobs", + "max_completion_tokens", + "max_tokens", + "metadata", + "model_kwargs", + "model_name", + "n", + "name", + "output_version", + "presence_penalty", + "profile", + "rate_limiter", + "reasoning", + "reasoning_effort", + "seed", + "service_tier", + "stop", + "stop_sequences", + "store", + "stream_chunk_timeout", + "stream_usage", + "streaming", + "tags", + "temperature", + "tiktoken_model_name", + "top_logprobs", + "top_p", + "truncation", + "use_previous_response_id", + "use_responses_api", + "verbose", + "verbosity", + } +) -@functools.cache -def pydantic_accept_keys(model_cls: Any) -> frozenset[str]: - """Caches the accept-set for a LangChain chat model class, keyed by the class object itself. +#: Named for the drift test and for review, not read at runtime: the forwarded list above already +#: leaves these out, so nothing needs to subtract them again. +_CHAT_OPENAI_EXCLUDED_KEYS = frozenset( + { + "api_key", + "openai_api_key", + "base_url", + "openai_api_base", + "organization", + "openai_organization", + "openai_proxy", + "client", + "async_client", + "root_client", + "root_async_client", + "http_client", + "http_async_client", + "http_socket_options", + "default_headers", + "default_query", + "max_retries", + "request_timeout", + "timeout", + "extra_body", + } +) - Computed once per class rather than per call: field introspection is cheap but there is no - reason to repeat it on every invocation of a hot path. - """ - return accepted_parameter_keys_from_pydantic_model(model_cls) +#: Every key ``ChatAnthropic`` accepts (field names plus pydantic aliases), classified the same way +#: as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``TestChatAnthropicAcceptsExactlyTheseKeys`` in this +#: package's tests asserts this classification stays exhaustive. +#: +#: Excluded, and why: all client/connection configuration: +#: * ``anthropic_api_key``, ``api_key``: the API key (field plus alias). +#: * ``anthropic_api_url``, ``base_url``: the API base URL (field plus alias). +#: * ``anthropic_proxy``: an HTTP proxy. +#: * ``default_request_timeout``, ``timeout``: a request timeout (field plus alias). +#: * ``max_retries``: an HTTP retry count. +#: * ``default_headers``: default request headers. +_CHAT_ANTHROPIC_FORWARDED_KEYS = frozenset( + { + "betas", + "cache", + "callbacks", + "context_management", + "custom_get_token_ids", + "disable_streaming", + "effort", + "inference_geo", + "max_tokens", + "max_tokens_to_sample", + "mcp_servers", + "metadata", + "model_kwargs", + "model_name", + "name", + "output_config", + "output_version", + "profile", + "rate_limiter", + "reuse_last_container", + "stop", + "stop_sequences", + "stream_usage", + "streaming", + "tags", + "temperature", + "thinking", + "top_k", + "top_p", + "verbose", + } +) + +#: Named for the drift test and for review, not read at runtime. +_CHAT_ANTHROPIC_EXCLUDED_KEYS = frozenset( + { + "anthropic_api_key", + "api_key", + "anthropic_api_url", + "base_url", + "anthropic_proxy", + "default_request_timeout", + "timeout", + "max_retries", + "default_headers", + } +) + +#: Every key ``ChatBedrockConverse`` accepts (field names plus pydantic aliases), classified the +#: same way as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``langchain-aws`` is not a dependency of this +#: package (Bedrock support is opt-in, see ``_make_default_chat_model``), so +#: ``TestChatBedrockConverseAcceptsExactlyTheseKeys`` in this package's tests skips itself when it +#: is not installed rather than asserting nothing. +#: +#: Excluded, and why: all client/connection configuration: +#: * ``bedrock_api_key``, ``api_key``: the API key (field plus alias). +#: * ``aws_access_key_id``, ``aws_secret_access_key``, ``aws_session_token``: AWS credentials. +#: * ``credentials_profile_name``: the AWS credentials profile used for auth. +#: * ``endpoint_url``, ``base_url``: the API base URL (field plus alias). +#: * ``client``, ``bedrock_client``: raw HTTP/boto3 client objects. +#: * ``config``: a raw botocore client config object. +#: * ``default_headers``: default request headers. +#: * ``max_retries``: an HTTP retry count. +#: * ``timeout``: a request timeout. +_CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = frozenset( + { + "additional_model_request_fields", + "additional_model_response_field_paths", + "base_model", + "base_model_id", + "cache", + "callbacks", + "custom_get_token_ids", + "disable_streaming", + "guard_last_turn_only", + "guardrail_config", + "guardrails", + "max_tokens", + "metadata", + "model_id", + "name", + "output_config", + "output_version", + "performance_config", + "profile", + "provider", + "rate_limiter", + "raw_blocks", + "reasoning_effort", + "region_name", + "request_metadata", + "service_tier", + "stop", + "stop_sequences", + "streaming", + "supports_tool_choice_values", + "system", + "tags", + "temperature", + "top_p", + "verbose", + } +) + +#: Named for the drift test and for review, not read at runtime. +_CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS = frozenset( + { + "bedrock_api_key", + "api_key", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "credentials_profile_name", + "endpoint_url", + "base_url", + "client", + "bedrock_client", + "config", + "default_headers", + "max_retries", + "timeout", + } +) def _build_tools(config_tools: dict[str, Any]) -> list[dict[str, Any]]: @@ -240,16 +459,16 @@ def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: def _model_constructor_kwargs( - config: AiConfigRep, fallback_name: str, model_cls: Any = None + config: AiConfigRep, + fallback_name: str, + forwarded_keys: frozenset[str] | None = None, ) -> dict[str, Any]: parameters = model_parameters(config) provider = str((config.get("provider") or {}).get("name") or "").lower() if provider == "bedrock": parameters.pop("tools", None) - if model_cls is not None and parameters: - parameters = filter_forwardable_parameters( - parameters, pydantic_accept_keys(model_cls) - ) + if forwarded_keys is not None and parameters: + parameters = select_forwarded_parameters(parameters, forwarded_keys) # 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 @@ -272,7 +491,7 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: lc_anthropic = importlib.import_module("langchain_anthropic") return lc_anthropic.ChatAnthropic( **_model_constructor_kwargs( - config, "claude-3-5-sonnet-20241022", lc_anthropic.ChatAnthropic + config, "claude-3-5-sonnet-20241022", _CHAT_ANTHROPIC_FORWARDED_KEYS ) ) if provider == "bedrock": @@ -284,11 +503,13 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: "Install it with: pip install langchain-aws" ) from exc return lc_aws.ChatBedrockConverse( - **_model_constructor_kwargs(config, "", lc_aws.ChatBedrockConverse) + **_model_constructor_kwargs( + config, "", _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS + ) ) lc_openai = importlib.import_module("langchain_openai") return lc_openai.ChatOpenAI( - **_model_constructor_kwargs(config, "gpt-4o", lc_openai.ChatOpenAI) + **_model_constructor_kwargs(config, "gpt-4o", _CHAT_OPENAI_FORWARDED_KEYS) ) diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index 80774bdd..6482beb0 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -2660,8 +2660,11 @@ class TestModelParametersReachTheWire: """ @pytest.mark.asyncio - async def test_top_p_reaches_the_wire(self) -> None: + async def test_top_p_reaches_the_wire( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: import httpx + from langchain_openai.chat_models import base as _chat_openai_base ctx, _rec = _recording() from launchdarkly_ai_langchain_messages import create_langchain_messages_handler @@ -2693,18 +2696,65 @@ def _handler(request: httpx.Request) -> httpx.Response: ) transport = httpx.MockTransport(_handler) + mock_client = httpx.AsyncClient( + transport=transport, base_url="https://api.openai.com/v1" + ) + # ``http_async_client`` in ``model.parameters`` is client/connection configuration and is + # never forwarded any more (see ``_CHAT_OPENAI_FORWARDED_KEYS``), so the real ``ChatOpenAI`` + # is intercepted below its own default httpx client builder instead: the same mock transport, + # reached without a config value ever naming an HTTP client. The API key comes from the env + # var ``ChatOpenAI`` already falls back to, for the same reason. + monkeypatch.setattr( + _chat_openai_base, + "_get_default_async_httpx_client", + lambda *a, **kw: mock_client, + ) + monkeypatch.setenv("OPENAI_API_KEY", "test-key") cfg = { **CONFIG, - "model": { - "name": "gpt-4o", - "parameters": { - "top_p": 0.5, - "api_key": "test-key", - "http_async_client": httpx.AsyncClient(transport=transport), - }, - }, + "model": {"name": "gpt-4o", "parameters": {"top_p": 0.5}}, } with ctx: result = await create_langchain_messages_handler()(cfg, "q", {}, {}) assert captured["body"]["top_p"] == 0.5 assert result["output"] == "hi" + + +class TestConnectionConfigIsNeverForwarded: + """``api_key``/``base_url`` in ``model.parameters`` are client/connection configuration; a + config author must never be able to redirect a call to a different endpoint or credential. + """ + + @pytest.mark.parametrize( + "provider,fallback", [("openai", "gpt-4o"), ("anthropic", "claude")] + ) + def test_api_key_and_base_url_are_never_forwarded( + self, provider: str, fallback: str + ) -> None: + from launchdarkly_ai_langchain_messages.handler import ( + _CHAT_ANTHROPIC_FORWARDED_KEYS, + _CHAT_OPENAI_FORWARDED_KEYS, + _model_constructor_kwargs, + ) + + forwarded_keys = ( + _CHAT_OPENAI_FORWARDED_KEYS + if provider == "openai" + else _CHAT_ANTHROPIC_FORWARDED_KEYS + ) + cfg = { + **CONFIG, + "provider": {"name": provider}, + "model": { + "name": fallback, + "parameters": { + "api_key": "stolen-key", + "base_url": "https://evil.example.com", + "temperature": 0.3, + }, + }, + } + kwargs = _model_constructor_kwargs(cfg, fallback, forwarded_keys) + assert "api_key" not in kwargs + assert "base_url" not in kwargs + assert kwargs["temperature"] == 0.3 diff --git a/packages/langchain-messages/tests/test_parameter_forwarding.py b/packages/langchain-messages/tests/test_parameter_forwarding.py new file mode 100644 index 00000000..235aba7c --- /dev/null +++ b/packages/langchain-messages/tests/test_parameter_forwarding.py @@ -0,0 +1,139 @@ +""" +Drift test for the LangChain chat model parameter classification in ``handler.py``. + +``_CHAT_OPENAI_FORWARDED_KEYS`` / ``_CHAT_ANTHROPIC_FORWARDED_KEYS`` / +``_CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS`` are literal, hand-maintained lists. This test reads each +chat model's own ``model_fields`` (field names plus pydantic aliases) and asserts every key it +accepts is classified in exactly one of forwarded, handler-owned, or excluded, so a field nobody has +classified yet fails loudly by name, and so does a list entry that is not a real field. + +``langchain-aws`` is not a dependency of this package (Bedrock support is opt-in), so the +``ChatBedrockConverse`` test skips itself when it is not installed. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import langchain_anthropic +import langchain_openai +import pytest + +from launchdarkly_ai_langchain_messages.handler import ( + _CHAT_ANTHROPIC_EXCLUDED_KEYS, + _CHAT_ANTHROPIC_FORWARDED_KEYS, + _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS, + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, + _CHAT_OPENAI_EXCLUDED_KEYS, + _CHAT_OPENAI_FORWARDED_KEYS, +) + +#: Handler-owned on every model: always overwritten by the resolved model name after the filter +#: runs, see ``_model_constructor_kwargs``. +_OWNED_KEYS = frozenset({"model"}) + + +def _accepted_keys(cls: Any) -> frozenset[str]: + """Every key *cls* (a pydantic model) accepts by construction: each field's own name plus any + string alias it declares.""" + fields: Mapping[str, Any] = cls.model_fields + accepted: set[str] = set() + for name, field in fields.items(): + accepted.add(name) + alias = getattr(field, "alias", None) + if isinstance(alias, str): + accepted.add(alias) + validation_alias = getattr(field, "validation_alias", None) + if isinstance(validation_alias, str): + accepted.add(validation_alias) + else: + choices = getattr(validation_alias, "choices", None) + if choices: + accepted.update(c for c in choices if isinstance(c, str)) + return frozenset(accepted) + + +class TestChatOpenAIAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + accepted = _accepted_keys(langchain_openai.ChatOpenAI) + classified = ( + _CHAT_OPENAI_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_OPENAI_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"ChatOpenAI now accepts {sorted(unclassified)}, not classified as forwarded, " + "handler-owned, or excluded in langchain-messages handler.py" + ) + + overlap = ( + (_CHAT_OPENAI_FORWARDED_KEYS & _OWNED_KEYS) + | (_CHAT_OPENAI_FORWARDED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) + | (_OWNED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) + ) + assert not overlap, f"keys classified more than once: {sorted(overlap)}" + + def test_every_classified_key_is_real(self) -> None: + accepted = _accepted_keys(langchain_openai.ChatOpenAI) + stale = ( + _CHAT_OPENAI_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_OPENAI_EXCLUDED_KEYS + ) - accepted + assert not stale, ( + f"{sorted(stale)} classified in langchain-messages handler.py but " + "ChatOpenAI does not accept them" + ) + + +class TestChatAnthropicAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + accepted = _accepted_keys(langchain_anthropic.ChatAnthropic) + classified = ( + _CHAT_ANTHROPIC_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_ANTHROPIC_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"ChatAnthropic now accepts {sorted(unclassified)}, not classified as forwarded, " + "handler-owned, or excluded in langchain-messages handler.py" + ) + + def test_every_classified_key_is_real(self) -> None: + accepted = _accepted_keys(langchain_anthropic.ChatAnthropic) + stale = ( + _CHAT_ANTHROPIC_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_ANTHROPIC_EXCLUDED_KEYS + ) - accepted + assert not stale, ( + f"{sorted(stale)} classified in langchain-messages handler.py but " + "ChatAnthropic does not accept them" + ) + + +class TestChatBedrockConverseAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + lc_aws = pytest.importorskip("langchain_aws") + accepted = _accepted_keys(lc_aws.ChatBedrockConverse) + classified = ( + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS + | _OWNED_KEYS + | _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"ChatBedrockConverse now accepts {sorted(unclassified)}, not classified as " + "forwarded, handler-owned, or excluded in langchain-messages handler.py" + ) + + def test_every_classified_key_is_real(self) -> None: + lc_aws = pytest.importorskip("langchain_aws") + accepted = _accepted_keys(lc_aws.ChatBedrockConverse) + stale = ( + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS + | _OWNED_KEYS + | _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS + ) - accepted + assert not stale, ( + f"{sorted(stale)} classified in langchain-messages handler.py but " + "ChatBedrockConverse does not accept them" + ) diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index c0ec362c..d0d25365 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -23,15 +23,12 @@ from collections.abc import AsyncGenerator from typing import Any -from agents import ModelSettings as _ModelSettings - from launchdarkly_ai_server import ( AiConfigRep, LDContext, ProviderHandler, RunUsage, SpanUsage, - accepted_parameter_keys_from_dataclass, compose_history, config, content_to_text, @@ -39,10 +36,10 @@ create_run_usage, end_span_once, end_unfinished_spans, - filter_forwardable_parameters, image_block_to_url, model_parameters, parse_template, + select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, @@ -76,11 +73,42 @@ except ImportError: # pragma: no cover - `agents` is a hard dependency of this package _RunHooksBase = object # type: ignore[assignment,misc] -#: Accepted keys derived once from the real ``agents.ModelSettings`` dataclass, never -#: hand-maintained. Independent of this handler's per-call, mocked ``importlib.import_module("agents")`` -#: used elsewhere for actually building the ``Agent``/``ModelSettings`` instances: this is the one -#: place that needs the real SDK type, not a test double. -_MODEL_SETTINGS_KEYS = accepted_parameter_keys_from_dataclass(_ModelSettings) +#: Every field ``agents.ModelSettings`` declares, classified by hand into exactly one of: forwarded +#: (below) or excluded. ``ModelSettings`` has no handler-owned fields: ``model`` and ``max_turns`` +#: live outside it (on ``Agent``/``Runner.run``, see the ``.pop("max_turns", ...)`` below). +#: ``TestModelSettingsAcceptsExactlyTheseFields`` in this package's tests asserts this +#: classification stays exhaustive as the SDK's own dataclass changes. +#: +#: Excluded, and why: all client/connection configuration, never a config-controlled setting. +#: * ``retry``: an HTTP retry count. +#: * ``extra_headers``, ``extra_query``, ``extra_body``, ``extra_args``: raw HTTP/request overrides. +_MODEL_SETTINGS_FORWARDED_KEYS = frozenset( + { + "context_management", + "frequency_penalty", + "include_usage", + "max_tokens", + "metadata", + "parallel_tool_calls", + "presence_penalty", + "prompt_cache_retention", + "reasoning", + "response_include", + "store", + "temperature", + "tool_choice", + "top_logprobs", + "top_p", + "truncation", + "verbosity", + } +) + +#: Named for the drift test and for review, not read at runtime: the forwarded list above already +#: leaves these out, so nothing needs to subtract them again. +_MODEL_SETTINGS_EXCLUDED_KEYS = frozenset( + {"retry", "extra_headers", "extra_query", "extra_body", "extra_args"} +) def _build_agent_tools( @@ -224,8 +252,8 @@ def _build_agent_and_prompt( model_settings_params = model_parameters(config) # `max_turns` is a `Runner.run` option, not a `ModelSettings` field. model_settings_params.pop("max_turns", None) - model_settings_params = filter_forwardable_parameters( - model_settings_params, _MODEL_SETTINGS_KEYS + model_settings_params = select_forwarded_parameters( + model_settings_params, _MODEL_SETTINGS_FORWARDED_KEYS ) agent = Agent( name="assistant", diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index c00f637c..27f3143b 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -16,16 +16,16 @@ GraphNode, NativeTool, compose_history, - filter_forwardable_parameters, get_client, make_track_data, model_parameters, parse_template, + select_forwarded_parameters, to_ld_context, ) from .handler import ( - _MODEL_SETTINGS_KEYS, + _MODEL_SETTINGS_FORWARDED_KEYS, _parse_message_content, _to_openai_agent_items, ) @@ -180,8 +180,8 @@ async def _visit(node_key: str) -> None: node_model_settings_params = model_parameters(node.config) # `max_turns` is a `Runner.run` option, not a `ModelSettings` field. node_model_settings_params.pop("max_turns", None) - node_model_settings_params = filter_forwardable_parameters( - node_model_settings_params, _MODEL_SETTINGS_KEYS + node_model_settings_params = select_forwarded_parameters( + node_model_settings_params, _MODEL_SETTINGS_FORWARDED_KEYS ) agent = Agent( name=agent_name, diff --git a/packages/openai-agents/tests/test_parameter_forwarding.py b/packages/openai-agents/tests/test_parameter_forwarding.py new file mode 100644 index 00000000..c9020769 --- /dev/null +++ b/packages/openai-agents/tests/test_parameter_forwarding.py @@ -0,0 +1,43 @@ +""" +Drift test for the ``agents.ModelSettings`` parameter classification in ``handler.py``. + +``_MODEL_SETTINGS_FORWARDED_KEYS`` is a literal, hand-maintained list. This test reads +``ModelSettings``'s own dataclass fields and asserts every field it declares is classified in +exactly one of forwarded or excluded, so an SDK field nobody has classified yet fails loudly by +name, and so does a list entry that is not a real SDK field. ``ModelSettings`` has no handler-owned +fields: ``model`` and ``max_turns`` live outside it (on ``Agent``/``Runner.run``). +""" + +from __future__ import annotations + +import dataclasses + +from agents import ModelSettings + +from launchdarkly_ai_openai_agents.handler import ( + _MODEL_SETTINGS_EXCLUDED_KEYS as _EXCLUDED_KEYS, +) +from launchdarkly_ai_openai_agents.handler import _MODEL_SETTINGS_FORWARDED_KEYS + + +class TestModelSettingsAcceptsExactlyTheseFields: + def test_every_field_is_classified_exactly_once(self) -> None: + accepted = frozenset(f.name for f in dataclasses.fields(ModelSettings)) + classified = _MODEL_SETTINGS_FORWARDED_KEYS | _EXCLUDED_KEYS + + unclassified = accepted - classified + assert not unclassified, ( + f"ModelSettings now declares {sorted(unclassified)}, not classified as " + "forwarded or excluded in openai-agents handler.py" + ) + + overlap = _MODEL_SETTINGS_FORWARDED_KEYS & _EXCLUDED_KEYS + assert not overlap, f"fields classified more than once: {sorted(overlap)}" + + def test_every_classified_field_is_real(self) -> None: + accepted = frozenset(f.name for f in dataclasses.fields(ModelSettings)) + stale = (_MODEL_SETTINGS_FORWARDED_KEYS | _EXCLUDED_KEYS) - accepted + assert not stale, ( + f"{sorted(stale)} classified in openai-agents handler.py but " + "ModelSettings does not declare them" + ) diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index 89b8bd87..826b1b2b 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -5,8 +5,6 @@ from collections.abc import AsyncGenerator from typing import Any -from openai.resources.responses import AsyncResponses as _AsyncResponses - from launchdarkly_ai_server import ( AiConfigRep, LDContext, @@ -14,7 +12,6 @@ RunUsage, SpanMessage, SpanMessagePart, - accepted_parameter_keys_from_signature, compose_history, config, content_to_text, @@ -22,11 +19,11 @@ create_run_usage, end_span_once, end_unfinished_spans, - filter_forwardable_parameters, image_block_to_url, is_content_blocks, model_parameters, parse_template, + select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, @@ -51,26 +48,76 @@ tool_arguments, ) -#: Accepted keys derived once from ``AsyncResponses.create``/``.stream``'s own signatures, never -#: hand-maintained. Neither has a ``**kwargs`` catch-all. Confirms the UI offers several keys the -#: Responses API has never accepted: ``max_tokens``, ``frequency_penalty``, ``presence_penalty``, -#: ``seed``, ``n``, ``stop``, ``response_format``, ``logit_bias``, ``logprobs``, -#: ``max_completion_tokens``, ``audio``, ``modalities``, ``prediction``. -_RESPONSES_CREATE_KEYS = accepted_parameter_keys_from_signature(_AsyncResponses.create) -_RESPONSES_STREAM_KEYS = accepted_parameter_keys_from_signature(_AsyncResponses.stream) - -#: Keys the Responses API signature accepts but that would break this handler if a config set -#: them, because the handler itself already decides that behaviour. Everything else the API -#: accepts is forwarded, including keys that are not strictly generation settings (``store``, -#: ``user``, ``safety_identifier``, ``prompt_cache_key``, ``prompt_cache_retention``, ``include``, -#: ``context_management``, ``metadata``, ``service_tier``, ``instructions``, ``moderation``, ...). +#: Every key ``AsyncResponses.create``/``.stream`` accept, classified by hand into exactly one of: +#: forwarded (below), handler-owned (``model``, ``input``, ``previous_response_id``, ``tools``, +#: ``text``, popped after the filter runs, at each call site), or excluded. +#: ``TestResponsesCreateAcceptsExactlyTheseKeys`` / ``TestResponsesStreamAcceptsExactlyTheseKeys`` +#: in this package's tests assert this classification stays exhaustive as the SDK's own signatures +#: change. Confirms the UI offers several keys the Responses API has never accepted: ``max_tokens``, +#: ``frequency_penalty``, ``presence_penalty``, ``seed``, ``n``, ``stop``, ``response_format``, +#: ``logit_bias``, ``logprobs``, ``max_completion_tokens``, ``audio``, ``modalities``, +#: ``prediction``. +#: +#: Excluded, and why: +#: * ``stream``: the handler chooses blocking vs. streaming itself, not via a kwarg. +#: * ``stream_options``: only meaningful together with ``stream=True``, which the handler controls. +#: * ``background``: returns before the output exists, so the handler would get no result. +#: * ``conversation``: server-side conversation state conflicts with the input the handler builds. +#: * ``prompt``: server-side prompt template conflicts with the input the handler builds. +#: * ``timeout``, ``extra_headers``, ``extra_query``, ``extra_body``: client/connection +#: configuration (a request timeout, raw HTTP overrides), never a config-controlled setting. +#: +#: Everything else the API accepts is forwarded, including keys that are not strictly generation +#: settings (``store``, ``user``, ``safety_identifier``, ``prompt_cache_key``, +#: ``prompt_cache_retention``, ``include``, ``context_management``, ``metadata``, +#: ``service_tier``, ``instructions``, ``moderation``, ...). +_RESPONSES_CREATE_FORWARDED_KEYS = frozenset( + { + "context_management", + "include", + "instructions", + "max_output_tokens", + "max_tool_calls", + "metadata", + "moderation", + "parallel_tool_calls", + "prompt_cache_key", + "prompt_cache_retention", + "reasoning", + "safety_identifier", + "service_tier", + "store", + "temperature", + "tool_choice", + "top_logprobs", + "top_p", + "truncation", + "user", + } +) + +#: Same as :data:`_RESPONSES_CREATE_FORWARDED_KEYS`, plus the resumption keys only ``.stream`` +#: accepts: ``response_id``, ``starting_after``, ``text_format``. +_RESPONSES_STREAM_FORWARDED_KEYS = _RESPONSES_CREATE_FORWARDED_KEYS | { + "response_id", + "starting_after", + "text_format", +} + +#: Named for the drift test and for review, not read at runtime: the forwarded lists above already +#: leave these out, so nothing needs to subtract them again. See the comment above for why each one +#: is here rather than forwarded. _RESPONSES_EXCLUDED_KEYS = frozenset( { - "stream", # the handler chooses blocking vs. streaming itself, not via a kwarg - "stream_options", # only meaningful together with stream=True, which the handler controls - "background", # returns before the output exists, so the handler would get no result - "conversation", # server-side conversation state conflicts with the input the handler builds - "prompt", # server-side prompt template conflicts with the input the handler builds + "stream", + "stream_options", + "background", + "conversation", + "prompt", + "timeout", + "extra_headers", + "extra_query", + "extra_body", } ) @@ -306,10 +353,9 @@ async def _call_impl( messages=root_messages, ) - extra_params = filter_forwardable_parameters( + extra_params = select_forwarded_parameters( _apply_max_output_tokens_rename(model_parameters(config)), - _RESPONSES_CREATE_KEYS, - _RESPONSES_EXCLUDED_KEYS, + _RESPONSES_CREATE_FORWARDED_KEYS, ) for _owned_key in ( "model", @@ -549,10 +595,9 @@ async def _stream_gen( tool_definitions=tool_definitions, ) - extra_params = filter_forwardable_parameters( + extra_params = select_forwarded_parameters( _apply_max_output_tokens_rename(model_parameters(config)), - _RESPONSES_STREAM_KEYS, - _RESPONSES_EXCLUDED_KEYS, + _RESPONSES_STREAM_FORWARDED_KEYS, ) for _owned_key in ( "model", diff --git a/packages/openai-messages/tests/test_parameter_forwarding.py b/packages/openai-messages/tests/test_parameter_forwarding.py new file mode 100644 index 00000000..a4b562a2 --- /dev/null +++ b/packages/openai-messages/tests/test_parameter_forwarding.py @@ -0,0 +1,88 @@ +""" +Drift test for the OpenAI Responses API parameter classification in ``handler.py``. + +``_RESPONSES_CREATE_FORWARDED_KEYS`` / ``_RESPONSES_STREAM_FORWARDED_KEYS`` are literal, +hand-maintained lists. This test reads ``AsyncResponses.create``/``.stream``'s own signature and +asserts every parameter they accept is classified in exactly one of forwarded, handler-owned, or +excluded, so an SDK parameter nobody has classified yet fails loudly by name, and so does a list +entry that is not a real SDK parameter. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable + +from openai.resources.responses import AsyncResponses + +from launchdarkly_ai_openai_messages.handler import ( + _RESPONSES_CREATE_FORWARDED_KEYS, + _RESPONSES_EXCLUDED_KEYS, + _RESPONSES_STREAM_FORWARDED_KEYS, +) + +#: Handler-owned: always popped from the filtered params before the call, at both call sites. +_OWNED_KEYS = frozenset({"model", "input", "previous_response_id", "tools", "text"}) + + +def _signature_keys(fn: Callable[..., object]) -> frozenset[str]: + keys: set[str] = set() + for name, param in inspect.signature(fn).parameters.items(): + if name == "self" or param.kind is inspect.Parameter.VAR_POSITIONAL: + continue + assert param.kind is not inspect.Parameter.VAR_KEYWORD, ( + f"{fn!r} now accepts **{name}; this test can no longer enumerate its accept-set" + ) + keys.add(name) + return keys + + +class TestResponsesCreateAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + accepted = _signature_keys(AsyncResponses.create) + classified = ( + _RESPONSES_CREATE_FORWARDED_KEYS | _OWNED_KEYS | _RESPONSES_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"AsyncResponses.create now accepts {sorted(unclassified)}, not classified as " + "forwarded, handler-owned, or excluded in openai-messages handler.py" + ) + + overlap = ( + (_RESPONSES_CREATE_FORWARDED_KEYS & _OWNED_KEYS) + | (_RESPONSES_CREATE_FORWARDED_KEYS & _RESPONSES_EXCLUDED_KEYS) + | (_OWNED_KEYS & _RESPONSES_EXCLUDED_KEYS) + ) + assert not overlap, f"keys classified more than once: {sorted(overlap)}" + + def test_every_classified_key_is_a_real_parameter(self) -> None: + accepted = _signature_keys(AsyncResponses.create) + stale = (_RESPONSES_CREATE_FORWARDED_KEYS | _OWNED_KEYS) - accepted + assert not stale, ( + f"{sorted(stale)} classified in openai-messages handler.py but " + "AsyncResponses.create does not accept them" + ) + + +class TestResponsesStreamAcceptsExactlyTheseKeys: + def test_every_accepted_key_is_classified_exactly_once(self) -> None: + accepted = _signature_keys(AsyncResponses.stream) + classified = ( + _RESPONSES_STREAM_FORWARDED_KEYS | _OWNED_KEYS | _RESPONSES_EXCLUDED_KEYS + ) + + unclassified = accepted - classified + assert not unclassified, ( + f"AsyncResponses.stream now accepts {sorted(unclassified)}, not classified as " + "forwarded, handler-owned, or excluded in openai-messages handler.py" + ) + + def test_every_classified_key_is_a_real_parameter(self) -> None: + accepted = _signature_keys(AsyncResponses.stream) + stale = (_RESPONSES_STREAM_FORWARDED_KEYS | _OWNED_KEYS) - accepted + assert not stale, ( + f"{sorted(stale)} classified in openai-messages handler.py but " + "AsyncResponses.stream does not accept them" + ) From d51199d978cb37cb4344637f289bf0c513070431 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 14:34:04 -0400 Subject: [PATCH 04/26] fix(langchain): never forward model_name or model_id from model.parameters They are the same constructor field as `model`, under its field name or its alias. The handler always sets `model` itself, so a config value for either collided with the resolved model name or overrode it. Classify both as handler-owned per model class, and have the drift tests read that set from the handler instead of a test-local copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../handler.py | 11 ++- .../tests/test_parameter_forwarding.py | 67 +++++++++++++++---- .../handler.py | 11 ++- .../tests/test_parameter_forwarding.py | 67 +++++++++++++++---- 4 files changed, 126 insertions(+), 30 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 1dade9d0..9ceaeb08 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -44,6 +44,14 @@ to_tool_definitions, ) +#: Handler-owned, per model class. The handler always sets the model itself, and each +#: class exposes that one constructor field under two names (the field and its alias), so a +#: config value for either must never be forwarded: it would collide with the model the +#: handler resolves, or override it. +_CHAT_OPENAI_OWNED_KEYS = frozenset({"model", "model_name"}) +_CHAT_ANTHROPIC_OWNED_KEYS = frozenset({"model", "model_name"}) +_CHAT_BEDROCK_CONVERSE_OWNED_KEYS = frozenset({"model", "model_id"}) + #: Every key ``ChatOpenAI`` accepts (field names plus pydantic aliases), classified by hand into #: exactly one of: forwarded (below), handler-owned (``model``, always overwritten by the #: resolved model name, see ``_model_constructor_kwargs``), or excluded. ``TestChatOpenAIAccepts @@ -81,7 +89,6 @@ "max_tokens", "metadata", "model_kwargs", - "model_name", "n", "name", "output_version", @@ -164,7 +171,6 @@ "mcp_servers", "metadata", "model_kwargs", - "model_name", "name", "output_config", "output_version", @@ -230,7 +236,6 @@ "guardrails", "max_tokens", "metadata", - "model_id", "name", "output_config", "output_version", diff --git a/packages/langchain-agents/tests/test_parameter_forwarding.py b/packages/langchain-agents/tests/test_parameter_forwarding.py index 41f4a249..5c6b28bc 100644 --- a/packages/langchain-agents/tests/test_parameter_forwarding.py +++ b/packages/langchain-agents/tests/test_parameter_forwarding.py @@ -23,16 +23,16 @@ from launchdarkly_ai_langchain_agents.handler import ( _CHAT_ANTHROPIC_EXCLUDED_KEYS, _CHAT_ANTHROPIC_FORWARDED_KEYS, + _CHAT_ANTHROPIC_OWNED_KEYS, _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS, _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, + _CHAT_BEDROCK_CONVERSE_OWNED_KEYS, _CHAT_OPENAI_EXCLUDED_KEYS, _CHAT_OPENAI_FORWARDED_KEYS, + _CHAT_OPENAI_OWNED_KEYS, + _model_constructor_kwargs, ) -#: Handler-owned on every model: always overwritten by the resolved model name after the filter -#: runs, see ``_model_constructor_kwargs``. -_OWNED_KEYS = frozenset({"model"}) - def _accepted_keys(cls: Any) -> frozenset[str]: """Every key *cls* (a pydantic model) accepts by construction: each field's own name plus any @@ -58,7 +58,9 @@ class TestChatOpenAIAcceptsExactlyTheseKeys: def test_every_accepted_key_is_classified_exactly_once(self) -> None: accepted = _accepted_keys(langchain_openai.ChatOpenAI) classified = ( - _CHAT_OPENAI_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_OPENAI_EXCLUDED_KEYS + _CHAT_OPENAI_FORWARDED_KEYS + | _CHAT_OPENAI_OWNED_KEYS + | _CHAT_OPENAI_EXCLUDED_KEYS ) unclassified = accepted - classified @@ -68,16 +70,18 @@ def test_every_accepted_key_is_classified_exactly_once(self) -> None: ) overlap = ( - (_CHAT_OPENAI_FORWARDED_KEYS & _OWNED_KEYS) + (_CHAT_OPENAI_FORWARDED_KEYS & _CHAT_OPENAI_OWNED_KEYS) | (_CHAT_OPENAI_FORWARDED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) - | (_OWNED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) + | (_CHAT_OPENAI_OWNED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) ) assert not overlap, f"keys classified more than once: {sorted(overlap)}" def test_every_classified_key_is_real(self) -> None: accepted = _accepted_keys(langchain_openai.ChatOpenAI) stale = ( - _CHAT_OPENAI_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_OPENAI_EXCLUDED_KEYS + _CHAT_OPENAI_FORWARDED_KEYS + | _CHAT_OPENAI_OWNED_KEYS + | _CHAT_OPENAI_EXCLUDED_KEYS ) - accepted assert not stale, ( f"{sorted(stale)} classified in langchain-agents handler.py but " @@ -89,7 +93,9 @@ class TestChatAnthropicAcceptsExactlyTheseKeys: def test_every_accepted_key_is_classified_exactly_once(self) -> None: accepted = _accepted_keys(langchain_anthropic.ChatAnthropic) classified = ( - _CHAT_ANTHROPIC_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_ANTHROPIC_EXCLUDED_KEYS + _CHAT_ANTHROPIC_FORWARDED_KEYS + | _CHAT_ANTHROPIC_OWNED_KEYS + | _CHAT_ANTHROPIC_EXCLUDED_KEYS ) unclassified = accepted - classified @@ -101,7 +107,9 @@ def test_every_accepted_key_is_classified_exactly_once(self) -> None: def test_every_classified_key_is_real(self) -> None: accepted = _accepted_keys(langchain_anthropic.ChatAnthropic) stale = ( - _CHAT_ANTHROPIC_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_ANTHROPIC_EXCLUDED_KEYS + _CHAT_ANTHROPIC_FORWARDED_KEYS + | _CHAT_ANTHROPIC_OWNED_KEYS + | _CHAT_ANTHROPIC_EXCLUDED_KEYS ) - accepted assert not stale, ( f"{sorted(stale)} classified in langchain-agents handler.py but " @@ -115,7 +123,7 @@ def test_every_accepted_key_is_classified_exactly_once(self) -> None: accepted = _accepted_keys(lc_aws.ChatBedrockConverse) classified = ( _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS - | _OWNED_KEYS + | _CHAT_BEDROCK_CONVERSE_OWNED_KEYS | _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS ) @@ -130,10 +138,45 @@ def test_every_classified_key_is_real(self) -> None: accepted = _accepted_keys(lc_aws.ChatBedrockConverse) stale = ( _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS - | _OWNED_KEYS + | _CHAT_BEDROCK_CONVERSE_OWNED_KEYS | _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS ) - accepted assert not stale, ( f"{sorted(stale)} classified in langchain-agents handler.py but " "ChatBedrockConverse does not accept them" ) + + +class TestModelFieldAliasesAreNeverForwarded: + """``model_name`` / ``model_id`` are the same constructor field as ``model``. A config value + for them must not be forwarded, or it would collide with the model the handler resolves.""" + + def _config(self, provider: str, parameters: dict[str, Any]) -> Any: + return { + "model": {"name": "configured-model", "parameters": parameters}, + "provider": {"name": provider}, + } + + def test_openai_model_name_is_dropped(self) -> None: + kwargs = _model_constructor_kwargs( + self._config("openai", {"model_name": "other", "temperature": 0.2}), + "fallback", + _CHAT_OPENAI_FORWARDED_KEYS, + ) + assert kwargs == {"model": "configured-model", "temperature": 0.2} + + def test_anthropic_model_name_is_dropped(self) -> None: + kwargs = _model_constructor_kwargs( + self._config("anthropic", {"model_name": "other", "temperature": 0.2}), + "fallback", + _CHAT_ANTHROPIC_FORWARDED_KEYS, + ) + assert kwargs == {"model": "configured-model", "temperature": 0.2} + + def test_bedrock_model_id_is_dropped(self) -> None: + kwargs = _model_constructor_kwargs( + self._config("bedrock", {"model_id": "other", "temperature": 0.2}), + "fallback", + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, + ) + assert kwargs == {"model": "configured-model", "temperature": 0.2} 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 2e025634..4ad413a4 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -47,6 +47,14 @@ to_tool_definitions, ) +#: Handler-owned, per model class. The handler always sets the model itself, and each +#: class exposes that one constructor field under two names (the field and its alias), so a +#: config value for either must never be forwarded: it would collide with the model the +#: handler resolves, or override it. +_CHAT_OPENAI_OWNED_KEYS = frozenset({"model", "model_name"}) +_CHAT_ANTHROPIC_OWNED_KEYS = frozenset({"model", "model_name"}) +_CHAT_BEDROCK_CONVERSE_OWNED_KEYS = frozenset({"model", "model_id"}) + #: Every key ``ChatOpenAI`` accepts (field names plus pydantic aliases), classified by hand into #: exactly one of: forwarded (below), handler-owned (``model``: always overwritten by the #: resolved model name, see ``_model_constructor_kwargs``), or excluded. ``TestChatOpenAIAccepts @@ -84,7 +92,6 @@ "max_tokens", "metadata", "model_kwargs", - "model_name", "n", "name", "output_version", @@ -167,7 +174,6 @@ "mcp_servers", "metadata", "model_kwargs", - "model_name", "name", "output_config", "output_version", @@ -233,7 +239,6 @@ "guardrails", "max_tokens", "metadata", - "model_id", "name", "output_config", "output_version", diff --git a/packages/langchain-messages/tests/test_parameter_forwarding.py b/packages/langchain-messages/tests/test_parameter_forwarding.py index 235aba7c..6d0eb133 100644 --- a/packages/langchain-messages/tests/test_parameter_forwarding.py +++ b/packages/langchain-messages/tests/test_parameter_forwarding.py @@ -23,16 +23,16 @@ from launchdarkly_ai_langchain_messages.handler import ( _CHAT_ANTHROPIC_EXCLUDED_KEYS, _CHAT_ANTHROPIC_FORWARDED_KEYS, + _CHAT_ANTHROPIC_OWNED_KEYS, _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS, _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, + _CHAT_BEDROCK_CONVERSE_OWNED_KEYS, _CHAT_OPENAI_EXCLUDED_KEYS, _CHAT_OPENAI_FORWARDED_KEYS, + _CHAT_OPENAI_OWNED_KEYS, + _model_constructor_kwargs, ) -#: Handler-owned on every model: always overwritten by the resolved model name after the filter -#: runs, see ``_model_constructor_kwargs``. -_OWNED_KEYS = frozenset({"model"}) - def _accepted_keys(cls: Any) -> frozenset[str]: """Every key *cls* (a pydantic model) accepts by construction: each field's own name plus any @@ -58,7 +58,9 @@ class TestChatOpenAIAcceptsExactlyTheseKeys: def test_every_accepted_key_is_classified_exactly_once(self) -> None: accepted = _accepted_keys(langchain_openai.ChatOpenAI) classified = ( - _CHAT_OPENAI_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_OPENAI_EXCLUDED_KEYS + _CHAT_OPENAI_FORWARDED_KEYS + | _CHAT_OPENAI_OWNED_KEYS + | _CHAT_OPENAI_EXCLUDED_KEYS ) unclassified = accepted - classified @@ -68,16 +70,18 @@ def test_every_accepted_key_is_classified_exactly_once(self) -> None: ) overlap = ( - (_CHAT_OPENAI_FORWARDED_KEYS & _OWNED_KEYS) + (_CHAT_OPENAI_FORWARDED_KEYS & _CHAT_OPENAI_OWNED_KEYS) | (_CHAT_OPENAI_FORWARDED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) - | (_OWNED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) + | (_CHAT_OPENAI_OWNED_KEYS & _CHAT_OPENAI_EXCLUDED_KEYS) ) assert not overlap, f"keys classified more than once: {sorted(overlap)}" def test_every_classified_key_is_real(self) -> None: accepted = _accepted_keys(langchain_openai.ChatOpenAI) stale = ( - _CHAT_OPENAI_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_OPENAI_EXCLUDED_KEYS + _CHAT_OPENAI_FORWARDED_KEYS + | _CHAT_OPENAI_OWNED_KEYS + | _CHAT_OPENAI_EXCLUDED_KEYS ) - accepted assert not stale, ( f"{sorted(stale)} classified in langchain-messages handler.py but " @@ -89,7 +93,9 @@ class TestChatAnthropicAcceptsExactlyTheseKeys: def test_every_accepted_key_is_classified_exactly_once(self) -> None: accepted = _accepted_keys(langchain_anthropic.ChatAnthropic) classified = ( - _CHAT_ANTHROPIC_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_ANTHROPIC_EXCLUDED_KEYS + _CHAT_ANTHROPIC_FORWARDED_KEYS + | _CHAT_ANTHROPIC_OWNED_KEYS + | _CHAT_ANTHROPIC_EXCLUDED_KEYS ) unclassified = accepted - classified @@ -101,7 +107,9 @@ def test_every_accepted_key_is_classified_exactly_once(self) -> None: def test_every_classified_key_is_real(self) -> None: accepted = _accepted_keys(langchain_anthropic.ChatAnthropic) stale = ( - _CHAT_ANTHROPIC_FORWARDED_KEYS | _OWNED_KEYS | _CHAT_ANTHROPIC_EXCLUDED_KEYS + _CHAT_ANTHROPIC_FORWARDED_KEYS + | _CHAT_ANTHROPIC_OWNED_KEYS + | _CHAT_ANTHROPIC_EXCLUDED_KEYS ) - accepted assert not stale, ( f"{sorted(stale)} classified in langchain-messages handler.py but " @@ -115,7 +123,7 @@ def test_every_accepted_key_is_classified_exactly_once(self) -> None: accepted = _accepted_keys(lc_aws.ChatBedrockConverse) classified = ( _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS - | _OWNED_KEYS + | _CHAT_BEDROCK_CONVERSE_OWNED_KEYS | _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS ) @@ -130,10 +138,45 @@ def test_every_classified_key_is_real(self) -> None: accepted = _accepted_keys(lc_aws.ChatBedrockConverse) stale = ( _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS - | _OWNED_KEYS + | _CHAT_BEDROCK_CONVERSE_OWNED_KEYS | _CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS ) - accepted assert not stale, ( f"{sorted(stale)} classified in langchain-messages handler.py but " "ChatBedrockConverse does not accept them" ) + + +class TestModelFieldAliasesAreNeverForwarded: + """``model_name`` / ``model_id`` are the same constructor field as ``model``. A config value + for them must not be forwarded, or it would collide with the model the handler resolves.""" + + def _config(self, provider: str, parameters: dict[str, Any]) -> Any: + return { + "model": {"name": "configured-model", "parameters": parameters}, + "provider": {"name": provider}, + } + + def test_openai_model_name_is_dropped(self) -> None: + kwargs = _model_constructor_kwargs( + self._config("openai", {"model_name": "other", "temperature": 0.2}), + "fallback", + _CHAT_OPENAI_FORWARDED_KEYS, + ) + assert kwargs == {"model": "configured-model", "temperature": 0.2} + + def test_anthropic_model_name_is_dropped(self) -> None: + kwargs = _model_constructor_kwargs( + self._config("anthropic", {"model_name": "other", "temperature": 0.2}), + "fallback", + _CHAT_ANTHROPIC_FORWARDED_KEYS, + ) + assert kwargs == {"model": "configured-model", "temperature": 0.2} + + def test_bedrock_model_id_is_dropped(self) -> None: + kwargs = _model_constructor_kwargs( + self._config("bedrock", {"model_id": "other", "temperature": 0.2}), + "fallback", + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, + ) + assert kwargs == {"model": "configured-model", "temperature": 0.2} From c25d33bbee714f1682a2afdd626f17f9bd5b739b Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 10:14:08 -0400 Subject: [PATCH 05/26] refactor(client): stop exporting model_parameters and select_forwarded_parameters from the package root Only the provider packages in this repo use these helpers, and anything in __all__ becomes public API at 1.0. Callers now import them from the modules that define them: launchdarkly_ai_server.utils and launchdarkly_ai_server.parameter_forwarding. --- .../src/launchdarkly_ai_claude_agents/handler.py | 4 ++-- .../src/launchdarkly_ai_claude_agents/native_graph.py | 4 ++-- .../src/launchdarkly_ai_claude_messages/handler.py | 4 ++-- packages/client/src/launchdarkly_ai_server/__init__.py | 5 ----- packages/client/tests/test_parameter_forwarding.py | 2 +- packages/client/tests/test_utils.py | 2 +- .../src/launchdarkly_ai_langchain_agents/handler.py | 4 ++-- .../src/launchdarkly_ai_langchain_agents/native_graph.py | 4 ++-- .../src/launchdarkly_ai_langchain_messages/handler.py | 4 ++-- .../src/launchdarkly_ai_openai_agents/handler.py | 4 ++-- .../src/launchdarkly_ai_openai_agents/native_graph.py | 4 ++-- .../src/launchdarkly_ai_openai_messages/handler.py | 4 ++-- 12 files changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 140120da..82c1c35c 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -41,14 +41,14 @@ create_handler, end_span_once, end_unfinished_spans, - model_parameters, parse_template, - select_forwarded_parameters, set_conversation_id_if_absent, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters from .spans import ( MCP_TOOL_PREFIX, diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index 8db6af43..19e96b96 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -18,10 +18,10 @@ NativeTool, get_client, make_track_data, - model_parameters, - select_forwarded_parameters, to_ld_context, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters try: from opentelemetry import trace diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index fc03be58..2de33466 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -18,14 +18,14 @@ end_span_once, end_unfinished_spans, is_content_blocks, - model_parameters, parse_template, - select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, to_semconv_finish_reason, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters from .spans import ( RawRunUsage, diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index 1c24ae53..4001239e 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -55,7 +55,6 @@ inspect_config, shutdown, ) -from .parameter_forwarding import select_forwarded_parameters from .registry import ( Registry, compose, @@ -112,7 +111,6 @@ end_unfinished_spans, lang_chain_span_usage, make_track_data, - model_parameters, model_stamps_from_meta, normalize_mode, number_or_zero, @@ -200,12 +198,9 @@ "RunSummary", "Scorer", "init_evaluations", - # parameter_forwarding - "select_forwarded_parameters", # utils "create_handler", "make_track_data", - "model_parameters", "model_stamps_from_meta", "omit_model_stamps", "normalize_mode", diff --git a/packages/client/tests/test_parameter_forwarding.py b/packages/client/tests/test_parameter_forwarding.py index 3f412ef5..a40da7ed 100644 --- a/packages/client/tests/test_parameter_forwarding.py +++ b/packages/client/tests/test_parameter_forwarding.py @@ -2,7 +2,7 @@ from __future__ import annotations -from launchdarkly_ai_server import select_forwarded_parameters +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters class TestSelectForwardedParameters: diff --git a/packages/client/tests/test_utils.py b/packages/client/tests/test_utils.py index 54b0e97d..7494bd7f 100644 --- a/packages/client/tests/test_utils.py +++ b/packages/client/tests/test_utils.py @@ -11,7 +11,6 @@ from launchdarkly_ai_server import ( create_handler, make_track_data, - model_parameters, model_stamps_from_meta, normalize_mode, omit_model_stamps, @@ -19,6 +18,7 @@ parse_template, parse_usage, ) +from launchdarkly_ai_server.utils import model_parameters # --------------------------------------------------------------------------- # ?3.1 parse_template 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 9ceaeb08..deb19424 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -25,12 +25,12 @@ lang_chain_content_text, lang_chain_span_messages, lang_chain_span_usage, - model_parameters, parse_template, - select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters from .messages import to_lang_chain_messages from .spans import ( diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index 299f175c..b67a2f5a 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -18,11 +18,11 @@ compose_history, get_client, make_track_data, - model_parameters, parse_template, - select_forwarded_parameters, to_ld_context, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters from .handler import _CHAT_OPENAI_FORWARDED_KEYS from .messages import to_lang_chain_messages 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 4ad413a4..c172ec04 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -25,14 +25,14 @@ lang_chain_finish_reasons, lang_chain_span_messages, lang_chain_span_usage, - model_parameters, number_or_zero, parse_template, - select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters from .spans import ( fail_span, diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index fd504aea..c856f646 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -38,14 +38,14 @@ end_span_once, end_unfinished_spans, image_block_to_url, - model_parameters, parse_template, - select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, text_message, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters from .spans import ( derive_finish_reason, diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index e9310fa1..7d020cf4 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -18,11 +18,11 @@ compose_history, get_client, make_track_data, - model_parameters, parse_template, - select_forwarded_parameters, to_ld_context, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters from .handler import ( _MODEL_SETTINGS_FORWARDED_KEYS, diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index 826b1b2b..6c04012e 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -21,13 +21,13 @@ end_unfinished_spans, image_block_to_url, is_content_blocks, - model_parameters, parse_template, - select_forwarded_parameters, set_input_content_attributes, set_output_content_attributes, set_tool_call_content_attributes, ) +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from launchdarkly_ai_server.utils import model_parameters from .spans import ( fail_span, From 828279f3abcfbdd54fad7bd58c8c9c552212d8ca Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:48:56 -0400 Subject: [PATCH 06/26] fix(claude-agents): forward only model and run settings from model.parameters ClaudeAgentOptions also configures the host process the SDK launches: which binary runs, its environment, working directory, file access, permission mode, settings, plugins, sandbox, and session state. Forwarding those from a config let anyone who can edit an AI Config launch their own binary or point the API key at another host. The forwarded list is now max_turns, max_thinking_tokens, thinking, effort, max_budget_usd, fallback_model, output_format and betas. Every other field is excluded, with the reason next to it. The loops that popped handler-owned keys are removed: none of those keys is on the forwarded list, so the filter already drops them. --- .../launchdarkly_ai_claude_agents/handler.py | 68 ++++++---- .../native_graph.py | 9 -- .../claude-agents/tests/test_native_graph.py | 45 +++++++ .../tests/test_parameter_forwarding.py | 81 +++++++++++- tests/never_forwarded.py | 121 ++++++++++++++++++ 5 files changed, 287 insertions(+), 37 deletions(-) create mode 100644 tests/never_forwarded.py diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 82c1c35c..33b0dcd9 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -70,39 +70,67 @@ #: Every field ``ClaudeAgentOptions`` declares, classified by hand into exactly one of: forwarded #: (below), handler-owned (``model``, ``allowed_tools``, ``mcp_servers``, ``hooks``, ``tools``, -#: ``system_prompt``, popped after the filter runs, at each call site), or excluded -#: (``extra_args``, a raw CLI-argument escape hatch, is client/connection configuration and is never -#: forwarded). ``TestClaudeAgentOptionsAcceptsExactlyTheseFields`` in this package's tests asserts -#: this classification stays exhaustive as the SDK's own dataclass changes. +#: ``system_prompt``, set by each call site itself), or excluded (below). +#: ``TestClaudeAgentOptionsAcceptsExactlyTheseFields`` in this package's tests asserts this +#: classification stays exhaustive as the SDK's own dataclass changes. +#: +#: Only model and run settings are forwarded: how the model thinks, how long the run may go, and +#: what it may spend. Everything that configures the host process the SDK launches (its binary, +#: environment, working directory, file access, permissions, settings files, plugins, sandbox, +#: session state) stays under the application's control, never a config's. #: #: The SDK offers no ``temperature``/``top_p``/``top_k``/``max_tokens``/``stop_sequences``/ #: ``tool_choice``/``metadata``, all of which the LaunchDarkly UI's model parameters panel offers -#: for other providers; forwarding one of those unfiltered raised ``TypeError`` before this filter -#: existed. +#: for other providers; they are dropped like any other key not listed here. _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS = frozenset( + { + "betas", + "effort", + "fallback_model", + "max_budget_usd", + "max_thinking_tokens", + "max_turns", + "output_format", + "thinking", + } +) + +#: Accepted by ``ClaudeAgentOptions`` but never forwarded, and why: +#: * ``cli_path``, ``env``, ``cwd``, ``add_dirs``, ``settings``, ``setting_sources``, ``plugins``, +#: ``skills``, ``sandbox``, ``user``, ``extra_args``: which binary runs, with what environment, +#: as which user, with what files, settings, plugins, and CLI arguments. A config that could set +#: these could run code on, or read files from, the host. +#: * ``permission_mode``, ``permission_prompt_tool_name``, ``can_use_tool``, ``disallowed_tools``, +#: ``strict_mcp_config``, ``agents``: what the agent is allowed to do and which tools or +#: subagents it gets. The handler wires tools and permissions itself. +#: * ``resume``, ``session_id``, ``fork_session``, ``continue_conversation``, ``session_store``, +#: ``session_store_flush``, ``enable_file_checkpointing``: session state on the host. +#: * ``stderr``, ``debug_stderr``, ``include_partial_messages``, ``include_hook_events``, +#: ``max_buffer_size``, ``load_timeout_ms``: process I/O and transport plumbing, including the +#: streamed message shape the handler reads. +#: * ``task_budget``: not one of the agreed run settings yet; ``max_turns`` and ``max_budget_usd`` +#: cover run limits. +#: +#: Named for the drift test and for review, not read at runtime: the forwarded list above already +#: leaves these out, so nothing needs to subtract them again. +_CLAUDE_AGENT_OPTIONS_EXCLUDED_KEYS = frozenset( { "add_dirs", "agents", - "betas", "can_use_tool", "cli_path", "continue_conversation", "cwd", "debug_stderr", "disallowed_tools", - "effort", "enable_file_checkpointing", "env", - "fallback_model", + "extra_args", "fork_session", "include_hook_events", "include_partial_messages", "load_timeout_ms", - "max_budget_usd", "max_buffer_size", - "max_thinking_tokens", - "max_turns", - "output_format", "permission_mode", "permission_prompt_tool_name", "plugins", @@ -117,15 +145,10 @@ "stderr", "strict_mcp_config", "task_budget", - "thinking", "user", } ) -#: Named for the drift test and for review, not read at runtime: the forwarded list above already -#: leaves this out, so nothing needs to subtract it again. -_CLAUDE_AGENT_OPTIONS_EXCLUDED_KEYS = frozenset({"extra_args"}) - # --------------------------------------------------------------------------- # Tool wiring # --------------------------------------------------------------------------- @@ -542,15 +565,6 @@ def _build_query_options( params = select_forwarded_parameters( model_parameters(config), _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS ) - for _owned_key in ( - "model", - "allowed_tools", - "mcp_servers", - "hooks", - "tools", - "system_prompt", - ): - params.pop(_owned_key, None) kwargs: dict[str, Any] = { **params, "model": config["model"]["name"], diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index 19e96b96..7a14733a 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -155,15 +155,6 @@ async def _run_query( params = select_forwarded_parameters( model_parameters(node.config), _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS ) - for _owned_key in ( - "model", - "tools", - "allowed_tools", - "mcp_servers", - "hooks", - "system_prompt", - ): - params.pop(_owned_key, None) options = ClaudeAgentOptions( **params, diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index e01900f4..aa243a18 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -14,6 +14,7 @@ import launchdarkly_ai_claude_agents.native_graph as _claude_ng from launchdarkly_ai_claude_agents.native_graph import to_claude_agents from launchdarkly_ai_server import GraphDefinition, GraphEdge, GraphNode, NativeTool +from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks # --------------------------------------------------------------------------- # Helpers @@ -777,3 +778,47 @@ async def test_stamps_conversation_id_on_graph_span(self) -> None: assert (graph_spans[0].attributes or {}).get( GEN_AI_CONVERSATION_ID ) == "thread-graph" + + +class TestNativeGraphModelParameters: + @pytest.mark.asyncio + async def test_node_options_take_run_settings_and_nothing_never_forwarded( + self, + ) -> None: + """Each node runs its own query with options built from its own config, so a node's + ``max_turns`` applies to that node.""" + mock_sdk = _make_sdk_mock("done") + nodes = { + "root": { + "key": "root", + "config": { + "model": { + "name": "claude-3", + "parameters": {**NEVER_FORWARDED_BAG, "max_turns": 7}, + }, + "instructions": "be helpful", + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + graph_def = _make_graph_def(nodes=nodes) + + captured_options: list[dict[str, Any]] = [] + mock_sdk.ClaudeAgentOptions = MagicMock( + side_effect=lambda **kw: (captured_options.append(kw), kw)[1] + ) + + with patch( + "importlib.import_module", + side_effect=lambda n: ( + mock_sdk if n == "claude_agent_sdk" else __import__(n) + ), + ): + await to_claude_agents(_make_def_promise(graph_def)).invoke("hi") + + assert captured_options + for opts in captured_options: + assert opts["max_turns"] == 7 + assert not find_leaks(opts) diff --git a/packages/claude-agents/tests/test_parameter_forwarding.py b/packages/claude-agents/tests/test_parameter_forwarding.py index 3878c703..336fde80 100644 --- a/packages/claude-agents/tests/test_parameter_forwarding.py +++ b/packages/claude-agents/tests/test_parameter_forwarding.py @@ -10,13 +10,18 @@ from __future__ import annotations import dataclasses +from typing import Any from claude_agent_sdk import ClaudeAgentOptions from launchdarkly_ai_claude_agents.handler import ( _CLAUDE_AGENT_OPTIONS_EXCLUDED_KEYS as _EXCLUDED_KEYS, ) -from launchdarkly_ai_claude_agents.handler import _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS +from launchdarkly_ai_claude_agents.handler import ( + _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS, + _build_query_options, +) +from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks #: Handler-owned: popped from the filtered params before ``ClaudeAgentOptions(**kwargs)`` is #: constructed, at every call site (``handler.py`` and ``native_graph.py``). @@ -52,3 +57,77 @@ def test_every_classified_field_is_real(self) -> None: f"{sorted(stale)} classified in claude-agents handler.py but " "ClaudeAgentOptions does not declare them" ) + + +def _config(parameters: dict[str, Any]) -> dict[str, Any]: + return { + "model": {"name": "claude-opus-4-5", "parameters": parameters}, + "provider": {"name": "Anthropic"}, + "instructions": "You are helpful.", + } + + +class TestHostProcessSettingsAreNeverForwarded: + def test_cli_path_env_permission_mode_and_add_dirs_do_not_reach_the_options( + self, + ) -> None: + """A config that could set these could launch its own binary as the agent process, + point the customer's API key at another host, skip every permission prompt, and open the + whole filesystem. ``_build_query_options`` must leave all four at their defaults.""" + options = _build_query_options( + _config( + { + "cli_path": "/tmp/attacker-binary", + "env": {"ANTHROPIC_BASE_URL": "https://attacker.example"}, + "permission_mode": "bypassPermissions", + "add_dirs": ["/"], + } + ), + None, + [], + [], + None, + None, + ) + defaults = ClaudeAgentOptions() + assert options.cli_path is None + assert options.env == defaults.env == {} + assert options.permission_mode is None + assert options.add_dirs == defaults.add_dirs == [] + + def test_no_never_forwarded_key_reaches_the_options(self) -> None: + options = _build_query_options( + _config(dict(NEVER_FORWARDED_BAG)), None, [], [], None, None + ) + assert not find_leaks(options) + + def test_the_agreed_run_settings_still_land(self) -> None: + options = _build_query_options( + _config( + { + **NEVER_FORWARDED_BAG, + "max_turns": 4, + "max_thinking_tokens": 2048, + "thinking": {"type": "adaptive"}, + "effort": "high", + "max_budget_usd": 1.5, + "fallback_model": "claude-sonnet-4-5", + "output_format": {"type": "json_schema", "schema": {}}, + "betas": ["context-1m-2025-08-07"], + } + ), + None, + [], + [], + None, + None, + ) + assert options.max_turns == 4 + assert options.max_thinking_tokens == 2048 + assert options.thinking == {"type": "adaptive"} + assert options.effort == "high" + assert options.max_budget_usd == 1.5 + assert options.fallback_model == "claude-sonnet-4-5" + assert options.output_format == {"type": "json_schema", "schema": {}} + assert options.betas == ["context-1m-2025-08-07"] + assert not find_leaks(options) diff --git a/tests/never_forwarded.py b/tests/never_forwarded.py new file mode 100644 index 00000000..62c15be9 --- /dev/null +++ b/tests/never_forwarded.py @@ -0,0 +1,121 @@ +"""The keys no handler may ever forward from ``model.parameters``, shared by every package's tests. + +Each handler forwards only an explicit allowlist of model and run settings and drops every other +key. This module is the other half of that rule: the keys that must never be on any allowlist, +whatever the provider SDK accepts. ``test_never_forwarded_parameters.py`` checks every handler's +forwarded lists against it, and each package's own tests check their real call sites with +:data:`NEVER_FORWARDED_BAG`. +""" + +from __future__ import annotations + +#: Credentials, endpoints and connection settings, raw request injection, remote tools, and the +#: Claude Agents host-process settings. +NEVER_FORWARDED_KEYS = frozenset( + { + # Credentials. + "api_key", + "openai_api_key", + "anthropic_api_key", + "bedrock_api_key", + "credentials", + "credentials_profile_name", + "auth_token", + "organization", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_region", + # Endpoints and connection. + "base_url", + "openai_api_base", + "anthropic_api_url", + "endpoint", + "endpoint_url", + "region", + "region_name", + "inference_geo", + "client_options", + "client", + "async_client", + "http_client", + "http_async_client", + "default_headers", + "default_query", + "timeout", + "request_timeout", + "default_request_timeout", + "max_retries", + "retry", + "proxies", + "openai_proxy", + "anthropic_proxy", + # Request injection. + "headers", + "extra_headers", + "extra_body", + "extra_query", + "extra_args", + "model_kwargs", + "additional_model_request_fields", + # Remote tools. + "mcp_servers", + # Claude Agents host-process settings. + "cli_path", + "env", + "cwd", + "add_dirs", + "permission_mode", + "settings", + "setting_sources", + "plugins", + "sandbox", + "resume", + "session_id", + "fork_session", + "continue_conversation", + "hooks", + "can_use_tool", + "stderr", + "session_store", + } +) + + +def leaked(key: str) -> str: + """A value no handler sets itself, so finding it anywhere in a provider call means *key* + leaked through from the config.""" + return f"leaked-from-config:{key}" + + +#: ``model.parameters`` holding every never-forwarded key, each set to its own :func:`leaked` +#: marker. +NEVER_FORWARDED_BAG = {key: leaked(key) for key in NEVER_FORWARDED_KEYS} + + +def find_leaks(value: object) -> set[str]: + """Every :func:`leaked` marker found anywhere inside *value* (dicts, lists, tuples, and object + attributes are searched).""" + found: set[str] = set() + seen: set[int] = set() + + def walk(v: object) -> None: + if id(v) in seen: + return + seen.add(id(v)) + if isinstance(v, str): + if v.startswith("leaked-from-config:"): + found.add(v.split(":", 1)[1]) + elif isinstance(v, dict): + for k, item in v.items(): + walk(k) + walk(item) + elif isinstance(v, (list, tuple, set, frozenset)): + for item in v: + walk(item) + elif hasattr(v, "__dict__") and not isinstance(v, type): + for item in vars(v).values(): + walk(item) + + walk(value) + return found From 38d463aede1c6438f381709cbbed5f5fe90ac287 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:48:56 -0400 Subject: [PATCH 07/26] fix(langchain): forward only model request settings to chat models model_kwargs is merged straight into the request payload, so it carried extra_headers and extra_query past the exclusions. mcp_servers let a config attach a remote MCP server that then receives the conversation. region_name and inference_geo move where data is processed. Bedrock's additional_model_request_fields is another unfiltered request bag. All of those are now excluded, along with LangChain's own runtime fields (callbacks, cache, tags, metadata, streaming mode, message format, ...), which configure how LangChain runs in this process rather than the request. _model_constructor_kwargs now requires its forwarded list, and the dead pop of tools for Bedrock is removed. --- .../handler.py | 291 ++++++++--------- .../tests/test_native_graph.py | 39 +++ .../tests/test_parameter_forwarding.py | 70 ++++- .../handler.py | 293 +++++++++--------- .../tests/test_parameter_forwarding.py | 70 ++++- 5 files changed, 452 insertions(+), 311 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 deb19424..d00b40d7 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -52,49 +52,45 @@ _CHAT_ANTHROPIC_OWNED_KEYS = frozenset({"model", "model_name"}) _CHAT_BEDROCK_CONVERSE_OWNED_KEYS = frozenset({"model", "model_id"}) +#: LangChain's own runtime fields, shared by every chat model class: caching, callbacks, rate +#: limiting, tracing tags and metadata, streaming mode, message format, and token counting. They +#: configure how LangChain runs the model in this process, not the model request, and several are +#: objects or callables a config cannot express. Never forwarded. +_LANGCHAIN_RUNTIME_KEYS = frozenset( + { + "cache", + "callbacks", + "custom_get_token_ids", + "disable_streaming", + "metadata", + "name", + "output_version", + "profile", + "rate_limiter", + "streaming", + "tags", + "verbose", + } +) + #: Every key ``ChatOpenAI`` accepts (field names plus pydantic aliases), classified by hand into #: exactly one of: forwarded (below), handler-owned (``model``, always overwritten by the -#: resolved model name, see ``_model_constructor_kwargs``), or excluded. ``TestChatOpenAIAccepts -#: ExactlyTheseKeys`` in this package's tests asserts this classification stays exhaustive as the -#: SDK's own pydantic model changes. -#: -#: Excluded, and why: all client/connection configuration, never a config-controlled setting. -#: * ``api_key``, ``openai_api_key``: the API key. -#: * ``base_url``, ``openai_api_base``: the API base URL. -#: * ``organization``, ``openai_organization``: the organization id used for auth. -#: * ``openai_proxy``: an HTTP proxy. -#: * ``client``, ``async_client``, ``root_client``, ``root_async_client``, ``http_client``, -#: ``http_async_client``, ``http_socket_options``: raw HTTP client objects/settings. -#: * ``default_headers``, ``default_query``: default request headers/query params. -#: * ``max_retries``: an HTTP retry count. -#: * ``request_timeout``, ``timeout``: request timeouts. -#: * ``extra_body``: a raw request-body override. +#: resolved model name, see ``_model_constructor_kwargs``), or excluded (below). +#: ``TestChatOpenAIAcceptsExactlyTheseKeys`` in this package's tests asserts this classification +#: stays exhaustive as the SDK's own pydantic model changes. #: -#: Everything else the model accepts is forwarded, including keys that are not strictly generation -#: settings (``store``, ``stream_usage``, ``tags``, ``metadata``, ``model_kwargs``, ...). +#: Only model request settings are forwarded. _CHAT_OPENAI_FORWARDED_KEYS = frozenset( { - "cache", - "callbacks", "context_management", - "custom_get_token_ids", - "disable_streaming", - "disabled_params", "frequency_penalty", "include", - "include_response_headers", "logit_bias", "logprobs", "max_completion_tokens", "max_tokens", - "metadata", - "model_kwargs", "n", - "name", - "output_version", "presence_penalty", - "profile", - "rate_limiter", "reasoning", "reasoning_effort", "seed", @@ -102,183 +98,175 @@ "stop", "stop_sequences", "store", - "stream_chunk_timeout", - "stream_usage", - "streaming", - "tags", "temperature", - "tiktoken_model_name", "top_logprobs", "top_p", "truncation", - "use_previous_response_id", - "use_responses_api", - "verbose", "verbosity", } ) +#: Accepted by ``ChatOpenAI`` but never forwarded, and why: +#: * ``api_key``, ``openai_api_key``, ``organization``, ``openai_organization``: credentials. +#: * ``base_url``, ``openai_api_base``, ``openai_proxy``: where requests go. +#: * ``client``, ``async_client``, ``root_client``, ``root_async_client``, ``http_client``, +#: ``http_async_client``, ``http_socket_options``: raw HTTP client objects/settings. +#: * ``default_headers``, ``default_query``, ``extra_body``, ``model_kwargs``: raw request +#: injection. ``model_kwargs`` is merged straight into the request payload, so it would carry +#: ``extra_headers``/``extra_query`` or any other excluded key past this list. +#: * ``max_retries``, ``request_timeout``, ``timeout``, ``stream_chunk_timeout``: retries and +#: timeouts. +#: * ``stream_usage``, ``include_response_headers``, ``disabled_params``, ``tiktoken_model_name``, +#: ``use_responses_api``, ``use_previous_response_id``: which API and response shape the handler +#: gets back, and how usage is reported to it. +#: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. +#: #: Named for the drift test and for review, not read at runtime: the forwarded list above already #: leaves these out, so nothing needs to subtract them again. -_CHAT_OPENAI_EXCLUDED_KEYS = frozenset( - { - "api_key", - "openai_api_key", - "base_url", - "openai_api_base", - "organization", - "openai_organization", - "openai_proxy", - "client", - "async_client", - "root_client", - "root_async_client", - "http_client", - "http_async_client", - "http_socket_options", - "default_headers", - "default_query", - "max_retries", - "request_timeout", - "timeout", - "extra_body", - } -) +_CHAT_OPENAI_EXCLUDED_KEYS = _LANGCHAIN_RUNTIME_KEYS | { + "api_key", + "openai_api_key", + "organization", + "openai_organization", + "base_url", + "openai_api_base", + "openai_proxy", + "client", + "async_client", + "root_client", + "root_async_client", + "http_client", + "http_async_client", + "http_socket_options", + "default_headers", + "default_query", + "extra_body", + "model_kwargs", + "max_retries", + "request_timeout", + "timeout", + "stream_chunk_timeout", + "stream_usage", + "include_response_headers", + "disabled_params", + "tiktoken_model_name", + "use_responses_api", + "use_previous_response_id", +} #: Every key ``ChatAnthropic`` accepts (field names plus pydantic aliases), classified the same way #: as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``TestChatAnthropicAcceptsExactlyTheseKeys`` in this #: package's tests asserts this classification stays exhaustive. -#: -#: Excluded, and why: all client/connection configuration: -#: * ``anthropic_api_key``, ``api_key``: the API key (field plus alias). -#: * ``anthropic_api_url``, ``base_url``: the API base URL (field plus alias). -#: * ``anthropic_proxy``: an HTTP proxy. -#: * ``default_request_timeout``, ``timeout``: a request timeout (field plus alias). -#: * ``max_retries``: an HTTP retry count. -#: * ``default_headers``: default request headers. _CHAT_ANTHROPIC_FORWARDED_KEYS = frozenset( { "betas", - "cache", - "callbacks", "context_management", - "custom_get_token_ids", - "disable_streaming", "effort", - "inference_geo", "max_tokens", "max_tokens_to_sample", - "mcp_servers", - "metadata", - "model_kwargs", - "name", "output_config", - "output_version", - "profile", - "rate_limiter", "reuse_last_container", "stop", "stop_sequences", - "stream_usage", - "streaming", - "tags", "temperature", "thinking", "top_k", "top_p", - "verbose", } ) +#: Accepted by ``ChatAnthropic`` but never forwarded, and why: +#: * ``anthropic_api_key``, ``api_key``: credentials (field plus alias). +#: * ``anthropic_api_url``, ``base_url``, ``anthropic_proxy``: where requests go. +#: * ``inference_geo``: the region inference runs in, which decides where data is processed. +#: * ``default_headers``, ``model_kwargs``: raw request injection. ``model_kwargs`` is merged +#: straight into the request, so it would carry any excluded key past this list. +#: * ``mcp_servers``: attaches remote MCP servers, which then receive the conversation. +#: * ``default_request_timeout``, ``timeout``, ``max_retries``: timeouts and retries. +#: * ``stream_usage``: how usage is reported back to the handler. +#: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. +#: #: Named for the drift test and for review, not read at runtime. -_CHAT_ANTHROPIC_EXCLUDED_KEYS = frozenset( - { - "anthropic_api_key", - "api_key", - "anthropic_api_url", - "base_url", - "anthropic_proxy", - "default_request_timeout", - "timeout", - "max_retries", - "default_headers", - } -) +_CHAT_ANTHROPIC_EXCLUDED_KEYS = _LANGCHAIN_RUNTIME_KEYS | { + "anthropic_api_key", + "api_key", + "anthropic_api_url", + "base_url", + "anthropic_proxy", + "inference_geo", + "default_headers", + "model_kwargs", + "mcp_servers", + "default_request_timeout", + "timeout", + "max_retries", + "stream_usage", +} #: Every key ``ChatBedrockConverse`` accepts (field names plus pydantic aliases), classified the #: same way as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``langchain-aws`` is not a dependency of this #: package (Bedrock support is opt-in, see ``_make_default_chat_model``), so #: ``TestChatBedrockConverseAcceptsExactlyTheseKeys`` in this package's tests skips itself when it #: is not installed rather than asserting nothing. -#: -#: Excluded, and why: all client/connection configuration: -#: * ``bedrock_api_key``, ``api_key``: the API key (field plus alias). -#: * ``aws_access_key_id``, ``aws_secret_access_key``, ``aws_session_token``: AWS credentials. -#: * ``credentials_profile_name``: the AWS credentials profile used for auth. -#: * ``endpoint_url``, ``base_url``: the API base URL (field plus alias). -#: * ``client``, ``bedrock_client``: raw HTTP/boto3 client objects. -#: * ``config``: a raw botocore client config object. -#: * ``default_headers``: default request headers. -#: * ``max_retries``: an HTTP retry count. -#: * ``timeout``: a request timeout. _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = frozenset( { - "additional_model_request_fields", - "additional_model_response_field_paths", - "base_model", - "base_model_id", - "cache", - "callbacks", - "custom_get_token_ids", - "disable_streaming", "guard_last_turn_only", "guardrail_config", "guardrails", "max_tokens", - "metadata", - "name", "output_config", - "output_version", "performance_config", - "profile", - "provider", - "rate_limiter", - "raw_blocks", "reasoning_effort", - "region_name", "request_metadata", "service_tier", "stop", "stop_sequences", - "streaming", - "supports_tool_choice_values", "system", - "tags", "temperature", "top_p", - "verbose", } ) +#: Accepted by ``ChatBedrockConverse`` but never forwarded, and why: +#: * ``bedrock_api_key``, ``api_key``, ``aws_access_key_id``, ``aws_secret_access_key``, +#: ``aws_session_token``, ``credentials_profile_name``: credentials. +#: * ``endpoint_url``, ``base_url``, ``region_name``: where requests go, and the region, which +#: decides where data is processed. +#: * ``client``, ``bedrock_client``, ``config``: raw boto3/botocore client objects and config. +#: * ``default_headers``, ``additional_model_request_fields``: raw request injection. +#: ``additional_model_request_fields`` is passed into the request body unfiltered. +#: * ``max_retries``, ``timeout``: retries and timeouts. +#: * ``base_model_id``, ``base_model``, ``provider``: which model the handler is talking to, the +#: same identity as ``model_id`` (handler-owned). +#: * ``additional_model_response_field_paths``, ``raw_blocks``, ``supports_tool_choice_values``: +#: the response shape and tool-calling behaviour the handler relies on. +#: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. +#: #: Named for the drift test and for review, not read at runtime. -_CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS = frozenset( - { - "bedrock_api_key", - "api_key", - "aws_access_key_id", - "aws_secret_access_key", - "aws_session_token", - "credentials_profile_name", - "endpoint_url", - "base_url", - "client", - "bedrock_client", - "config", - "default_headers", - "max_retries", - "timeout", - } -) +_CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS = _LANGCHAIN_RUNTIME_KEYS | { + "bedrock_api_key", + "api_key", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "credentials_profile_name", + "endpoint_url", + "base_url", + "region_name", + "client", + "bedrock_client", + "config", + "default_headers", + "additional_model_request_fields", + "max_retries", + "timeout", + "base_model_id", + "base_model", + "provider", + "additional_model_response_field_paths", + "raw_blocks", + "supports_tool_choice_values", +} def _build_agent_tools( @@ -410,14 +398,9 @@ def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: def _model_constructor_kwargs( config: AiConfigRep, fallback_name: str, - forwarded_keys: frozenset[str] | None = None, + forwarded_keys: frozenset[str], ) -> dict[str, Any]: - parameters = model_parameters(config) - provider = str((config.get("provider") or {}).get("name") or "").lower() - if provider == "bedrock": - parameters.pop("tools", None) - if forwarded_keys is not None and parameters: - parameters = select_forwarded_parameters(parameters, forwarded_keys) + parameters = select_forwarded_parameters(model_parameters(config), forwarded_keys) parameters["model"] = _resolved_model_name(config, fallback_name) return parameters diff --git a/packages/langchain-agents/tests/test_native_graph.py b/packages/langchain-agents/tests/test_native_graph.py index 3b9e79a7..d703c7cc 100644 --- a/packages/langchain-agents/tests/test_native_graph.py +++ b/packages/langchain-agents/tests/test_native_graph.py @@ -16,6 +16,7 @@ from launchdarkly_ai_langchain_agents.native_graph import _extract_usage, to_lang_graph from launchdarkly_ai_server import GraphDefinition, GraphEdge, GraphNode +from tests.never_forwarded import NEVER_FORWARDED_BAG # --------------------------------------------------------------------------- # Helpers @@ -930,3 +931,41 @@ def model_factory(_node: Any) -> Any: assert isinstance(result, dict) assert "response" in result + + +class TestNativeGraphNeverForwardedParameters: + @pytest.mark.asyncio + async def test_default_chat_openai_receives_no_never_forwarded_key(self) -> None: + ai_msg = _make_ai_msg("final") + mocks = _make_langgraph_mocks(ai_msg) + graph_def = _make_graph_def( + nodes={ + "root": { + "key": "root", + "config": { + "model": { + "name": "gpt-4o", + "parameters": {**NEVER_FORWARDED_BAG, "temperature": 0.2}, + }, + "instructions": "help", + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + ) + + async def _visit(fn: Any, ctx: Any = None) -> None: + if graph_def.root is not None: + await fn(graph_def.root) + + graph_def.traverse = _visit + + with _patch_imports(mocks): + await to_lang_graph(_make_def_promise(graph_def)).invoke("hi") + + assert mocks["langchain_openai"].ChatOpenAI.call_args.kwargs == { + "temperature": 0.2, + "model": "gpt-4o", + } diff --git a/packages/langchain-agents/tests/test_parameter_forwarding.py b/packages/langchain-agents/tests/test_parameter_forwarding.py index 5c6b28bc..9e19cff6 100644 --- a/packages/langchain-agents/tests/test_parameter_forwarding.py +++ b/packages/langchain-agents/tests/test_parameter_forwarding.py @@ -14,7 +14,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, ClassVar import langchain_anthropic import langchain_openai @@ -32,6 +32,7 @@ _CHAT_OPENAI_OWNED_KEYS, _model_constructor_kwargs, ) +from tests.never_forwarded import NEVER_FORWARDED_BAG def _accepted_keys(cls: Any) -> frozenset[str]: @@ -180,3 +181,70 @@ def test_bedrock_model_id_is_dropped(self) -> None: _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, ) assert kwargs == {"model": "configured-model", "temperature": 0.2} + + +class TestNeverForwardedKeys: + """No credential, endpoint, request-injection, or remote-tool key in ``model.parameters`` + reaches any chat model constructor.""" + + def _config(self, provider: str, parameters: dict[str, Any]) -> Any: + return { + "model": {"name": "configured-model", "parameters": parameters}, + "provider": {"name": provider}, + } + + @pytest.mark.parametrize( + ("provider", "forwarded_keys"), + [ + ("openai", _CHAT_OPENAI_FORWARDED_KEYS), + ("anthropic", _CHAT_ANTHROPIC_FORWARDED_KEYS), + ("bedrock", _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS), + ], + ) + def test_constructor_kwargs_hold_none_of_them( + self, provider: str, forwarded_keys: frozenset[str] + ) -> None: + kwargs = _model_constructor_kwargs( + self._config(provider, {**NEVER_FORWARDED_BAG, "temperature": 0.2}), + "fallback", + forwarded_keys, + ) + assert kwargs == {"model": "configured-model", "temperature": 0.2} + + +class TestModelKwargsCannotSmuggleRequestKeys: + """``ChatOpenAI`` merges ``model_kwargs`` straight into the request payload, so forwarding it + would carry ``extra_headers``/``extra_query`` past every exclusion.""" + + _SMUGGLED: ClassVar[dict[str, Any]] = { + "model_kwargs": { + "extra_headers": {"X-Smuggled": "1"}, + "extra_query": {"smuggled": "1"}, + } + } + + def _payload(self, **kwargs: Any) -> dict[str, Any]: + from langchain_core.messages import HumanMessage + + model = langchain_openai.ChatOpenAI(api_key="sk-test-not-a-real-key", **kwargs) + payload: dict[str, Any] = model._get_request_payload([HumanMessage("hi")]) + return payload + + def test_model_kwargs_would_reach_the_payload_if_forwarded(self) -> None: + """The control: passed to ``ChatOpenAI`` directly, both keys reach the payload.""" + payload = self._payload(model="gpt-4o", **self._SMUGGLED) + assert payload["extra_headers"] == {"X-Smuggled": "1"} + assert payload["extra_query"] == {"smuggled": "1"} + + def test_forwarded_model_parameters_keep_them_out_of_the_payload(self) -> None: + kwargs = _model_constructor_kwargs( + { + "model": {"name": "gpt-4o", "parameters": dict(self._SMUGGLED)}, + "provider": {"name": "openai"}, + }, + "fallback", + _CHAT_OPENAI_FORWARDED_KEYS, + ) + payload = self._payload(**kwargs) + assert "extra_headers" not in payload + assert "extra_query" not in payload 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 c172ec04..daadd8f7 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -55,49 +55,45 @@ _CHAT_ANTHROPIC_OWNED_KEYS = frozenset({"model", "model_name"}) _CHAT_BEDROCK_CONVERSE_OWNED_KEYS = frozenset({"model", "model_id"}) -#: Every key ``ChatOpenAI`` accepts (field names plus pydantic aliases), classified by hand into -#: exactly one of: forwarded (below), handler-owned (``model``: always overwritten by the -#: resolved model name, see ``_model_constructor_kwargs``), or excluded. ``TestChatOpenAIAccepts -#: ExactlyTheseKeys`` in this package's tests asserts this classification stays exhaustive as the -#: SDK's own pydantic model changes. -#: -#: Excluded, and why: all client/connection configuration, never a config-controlled setting: -#: * ``api_key``, ``openai_api_key``: the API key. -#: * ``base_url``, ``openai_api_base``: the API base URL. -#: * ``organization``, ``openai_organization``: the organization id used for auth. -#: * ``openai_proxy``: an HTTP proxy. -#: * ``client``, ``async_client``, ``root_client``, ``root_async_client``, ``http_client``, -#: ``http_async_client``, ``http_socket_options``: raw HTTP client objects/settings. -#: * ``default_headers``, ``default_query``: default request headers/query params. -#: * ``max_retries``: an HTTP retry count. -#: * ``request_timeout``, ``timeout``: request timeouts. -#: * ``extra_body``: a raw request-body override. -#: -#: Everything else the model accepts is forwarded, including keys that are not strictly generation -#: settings (``store``, ``stream_usage``, ``tags``, ``metadata``, ``model_kwargs``, ...). -_CHAT_OPENAI_FORWARDED_KEYS = frozenset( +#: LangChain's own runtime fields, shared by every chat model class: caching, callbacks, rate +#: limiting, tracing tags and metadata, streaming mode, message format, and token counting. They +#: configure how LangChain runs the model in this process, not the model request, and several are +#: objects or callables a config cannot express. Never forwarded. +_LANGCHAIN_RUNTIME_KEYS = frozenset( { "cache", "callbacks", - "context_management", "custom_get_token_ids", "disable_streaming", - "disabled_params", + "metadata", + "name", + "output_version", + "profile", + "rate_limiter", + "streaming", + "tags", + "verbose", + } +) + +#: Every key ``ChatOpenAI`` accepts (field names plus pydantic aliases), classified by hand into +#: exactly one of: forwarded (below), handler-owned (``model``, always overwritten by the +#: resolved model name, see ``_model_constructor_kwargs``), or excluded (below). +#: ``TestChatOpenAIAcceptsExactlyTheseKeys`` in this package's tests asserts this classification +#: stays exhaustive as the SDK's own pydantic model changes. +#: +#: Only model request settings are forwarded. +_CHAT_OPENAI_FORWARDED_KEYS = frozenset( + { + "context_management", "frequency_penalty", "include", - "include_response_headers", "logit_bias", "logprobs", "max_completion_tokens", "max_tokens", - "metadata", - "model_kwargs", "n", - "name", - "output_version", "presence_penalty", - "profile", - "rate_limiter", "reasoning", "reasoning_effort", "seed", @@ -105,183 +101,175 @@ "stop", "stop_sequences", "store", - "stream_chunk_timeout", - "stream_usage", - "streaming", - "tags", "temperature", - "tiktoken_model_name", "top_logprobs", "top_p", "truncation", - "use_previous_response_id", - "use_responses_api", - "verbose", "verbosity", } ) +#: Accepted by ``ChatOpenAI`` but never forwarded, and why: +#: * ``api_key``, ``openai_api_key``, ``organization``, ``openai_organization``: credentials. +#: * ``base_url``, ``openai_api_base``, ``openai_proxy``: where requests go. +#: * ``client``, ``async_client``, ``root_client``, ``root_async_client``, ``http_client``, +#: ``http_async_client``, ``http_socket_options``: raw HTTP client objects/settings. +#: * ``default_headers``, ``default_query``, ``extra_body``, ``model_kwargs``: raw request +#: injection. ``model_kwargs`` is merged straight into the request payload, so it would carry +#: ``extra_headers``/``extra_query`` or any other excluded key past this list. +#: * ``max_retries``, ``request_timeout``, ``timeout``, ``stream_chunk_timeout``: retries and +#: timeouts. +#: * ``stream_usage``, ``include_response_headers``, ``disabled_params``, ``tiktoken_model_name``, +#: ``use_responses_api``, ``use_previous_response_id``: which API and response shape the handler +#: gets back, and how usage is reported to it. +#: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. +#: #: Named for the drift test and for review, not read at runtime: the forwarded list above already #: leaves these out, so nothing needs to subtract them again. -_CHAT_OPENAI_EXCLUDED_KEYS = frozenset( - { - "api_key", - "openai_api_key", - "base_url", - "openai_api_base", - "organization", - "openai_organization", - "openai_proxy", - "client", - "async_client", - "root_client", - "root_async_client", - "http_client", - "http_async_client", - "http_socket_options", - "default_headers", - "default_query", - "max_retries", - "request_timeout", - "timeout", - "extra_body", - } -) +_CHAT_OPENAI_EXCLUDED_KEYS = _LANGCHAIN_RUNTIME_KEYS | { + "api_key", + "openai_api_key", + "organization", + "openai_organization", + "base_url", + "openai_api_base", + "openai_proxy", + "client", + "async_client", + "root_client", + "root_async_client", + "http_client", + "http_async_client", + "http_socket_options", + "default_headers", + "default_query", + "extra_body", + "model_kwargs", + "max_retries", + "request_timeout", + "timeout", + "stream_chunk_timeout", + "stream_usage", + "include_response_headers", + "disabled_params", + "tiktoken_model_name", + "use_responses_api", + "use_previous_response_id", +} #: Every key ``ChatAnthropic`` accepts (field names plus pydantic aliases), classified the same way #: as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``TestChatAnthropicAcceptsExactlyTheseKeys`` in this #: package's tests asserts this classification stays exhaustive. -#: -#: Excluded, and why: all client/connection configuration: -#: * ``anthropic_api_key``, ``api_key``: the API key (field plus alias). -#: * ``anthropic_api_url``, ``base_url``: the API base URL (field plus alias). -#: * ``anthropic_proxy``: an HTTP proxy. -#: * ``default_request_timeout``, ``timeout``: a request timeout (field plus alias). -#: * ``max_retries``: an HTTP retry count. -#: * ``default_headers``: default request headers. _CHAT_ANTHROPIC_FORWARDED_KEYS = frozenset( { "betas", - "cache", - "callbacks", "context_management", - "custom_get_token_ids", - "disable_streaming", "effort", - "inference_geo", "max_tokens", "max_tokens_to_sample", - "mcp_servers", - "metadata", - "model_kwargs", - "name", "output_config", - "output_version", - "profile", - "rate_limiter", "reuse_last_container", "stop", "stop_sequences", - "stream_usage", - "streaming", - "tags", "temperature", "thinking", "top_k", "top_p", - "verbose", } ) +#: Accepted by ``ChatAnthropic`` but never forwarded, and why: +#: * ``anthropic_api_key``, ``api_key``: credentials (field plus alias). +#: * ``anthropic_api_url``, ``base_url``, ``anthropic_proxy``: where requests go. +#: * ``inference_geo``: the region inference runs in, which decides where data is processed. +#: * ``default_headers``, ``model_kwargs``: raw request injection. ``model_kwargs`` is merged +#: straight into the request, so it would carry any excluded key past this list. +#: * ``mcp_servers``: attaches remote MCP servers, which then receive the conversation. +#: * ``default_request_timeout``, ``timeout``, ``max_retries``: timeouts and retries. +#: * ``stream_usage``: how usage is reported back to the handler. +#: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. +#: #: Named for the drift test and for review, not read at runtime. -_CHAT_ANTHROPIC_EXCLUDED_KEYS = frozenset( - { - "anthropic_api_key", - "api_key", - "anthropic_api_url", - "base_url", - "anthropic_proxy", - "default_request_timeout", - "timeout", - "max_retries", - "default_headers", - } -) +_CHAT_ANTHROPIC_EXCLUDED_KEYS = _LANGCHAIN_RUNTIME_KEYS | { + "anthropic_api_key", + "api_key", + "anthropic_api_url", + "base_url", + "anthropic_proxy", + "inference_geo", + "default_headers", + "model_kwargs", + "mcp_servers", + "default_request_timeout", + "timeout", + "max_retries", + "stream_usage", +} #: Every key ``ChatBedrockConverse`` accepts (field names plus pydantic aliases), classified the #: same way as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``langchain-aws`` is not a dependency of this #: package (Bedrock support is opt-in, see ``_make_default_chat_model``), so #: ``TestChatBedrockConverseAcceptsExactlyTheseKeys`` in this package's tests skips itself when it #: is not installed rather than asserting nothing. -#: -#: Excluded, and why: all client/connection configuration: -#: * ``bedrock_api_key``, ``api_key``: the API key (field plus alias). -#: * ``aws_access_key_id``, ``aws_secret_access_key``, ``aws_session_token``: AWS credentials. -#: * ``credentials_profile_name``: the AWS credentials profile used for auth. -#: * ``endpoint_url``, ``base_url``: the API base URL (field plus alias). -#: * ``client``, ``bedrock_client``: raw HTTP/boto3 client objects. -#: * ``config``: a raw botocore client config object. -#: * ``default_headers``: default request headers. -#: * ``max_retries``: an HTTP retry count. -#: * ``timeout``: a request timeout. _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = frozenset( { - "additional_model_request_fields", - "additional_model_response_field_paths", - "base_model", - "base_model_id", - "cache", - "callbacks", - "custom_get_token_ids", - "disable_streaming", "guard_last_turn_only", "guardrail_config", "guardrails", "max_tokens", - "metadata", - "name", "output_config", - "output_version", "performance_config", - "profile", - "provider", - "rate_limiter", - "raw_blocks", "reasoning_effort", - "region_name", "request_metadata", "service_tier", "stop", "stop_sequences", - "streaming", - "supports_tool_choice_values", "system", - "tags", "temperature", "top_p", - "verbose", } ) +#: Accepted by ``ChatBedrockConverse`` but never forwarded, and why: +#: * ``bedrock_api_key``, ``api_key``, ``aws_access_key_id``, ``aws_secret_access_key``, +#: ``aws_session_token``, ``credentials_profile_name``: credentials. +#: * ``endpoint_url``, ``base_url``, ``region_name``: where requests go, and the region, which +#: decides where data is processed. +#: * ``client``, ``bedrock_client``, ``config``: raw boto3/botocore client objects and config. +#: * ``default_headers``, ``additional_model_request_fields``: raw request injection. +#: ``additional_model_request_fields`` is passed into the request body unfiltered. +#: * ``max_retries``, ``timeout``: retries and timeouts. +#: * ``base_model_id``, ``base_model``, ``provider``: which model the handler is talking to, the +#: same identity as ``model_id`` (handler-owned). +#: * ``additional_model_response_field_paths``, ``raw_blocks``, ``supports_tool_choice_values``: +#: the response shape and tool-calling behaviour the handler relies on. +#: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. +#: #: Named for the drift test and for review, not read at runtime. -_CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS = frozenset( - { - "bedrock_api_key", - "api_key", - "aws_access_key_id", - "aws_secret_access_key", - "aws_session_token", - "credentials_profile_name", - "endpoint_url", - "base_url", - "client", - "bedrock_client", - "config", - "default_headers", - "max_retries", - "timeout", - } -) +_CHAT_BEDROCK_CONVERSE_EXCLUDED_KEYS = _LANGCHAIN_RUNTIME_KEYS | { + "bedrock_api_key", + "api_key", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "credentials_profile_name", + "endpoint_url", + "base_url", + "region_name", + "client", + "bedrock_client", + "config", + "default_headers", + "additional_model_request_fields", + "max_retries", + "timeout", + "base_model_id", + "base_model", + "provider", + "additional_model_response_field_paths", + "raw_blocks", + "supports_tool_choice_values", +} def _build_tools(config_tools: dict[str, Any]) -> list[dict[str, Any]]: @@ -466,14 +454,9 @@ def _config_for_model_call(config: AiConfigRep) -> AiConfigRep: def _model_constructor_kwargs( config: AiConfigRep, fallback_name: str, - forwarded_keys: frozenset[str] | None = None, + forwarded_keys: frozenset[str], ) -> dict[str, Any]: - parameters = model_parameters(config) - provider = str((config.get("provider") or {}).get("name") or "").lower() - if provider == "bedrock": - parameters.pop("tools", None) - if forwarded_keys is not None and parameters: - parameters = select_forwarded_parameters(parameters, forwarded_keys) + parameters = select_forwarded_parameters(model_parameters(config), forwarded_keys) # 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_parameter_forwarding.py b/packages/langchain-messages/tests/test_parameter_forwarding.py index 6d0eb133..3e079b30 100644 --- a/packages/langchain-messages/tests/test_parameter_forwarding.py +++ b/packages/langchain-messages/tests/test_parameter_forwarding.py @@ -14,7 +14,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, ClassVar import langchain_anthropic import langchain_openai @@ -32,6 +32,7 @@ _CHAT_OPENAI_OWNED_KEYS, _model_constructor_kwargs, ) +from tests.never_forwarded import NEVER_FORWARDED_BAG def _accepted_keys(cls: Any) -> frozenset[str]: @@ -180,3 +181,70 @@ def test_bedrock_model_id_is_dropped(self) -> None: _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, ) assert kwargs == {"model": "configured-model", "temperature": 0.2} + + +class TestNeverForwardedKeys: + """No credential, endpoint, request-injection, or remote-tool key in ``model.parameters`` + reaches any chat model constructor.""" + + def _config(self, provider: str, parameters: dict[str, Any]) -> Any: + return { + "model": {"name": "configured-model", "parameters": parameters}, + "provider": {"name": provider}, + } + + @pytest.mark.parametrize( + ("provider", "forwarded_keys"), + [ + ("openai", _CHAT_OPENAI_FORWARDED_KEYS), + ("anthropic", _CHAT_ANTHROPIC_FORWARDED_KEYS), + ("bedrock", _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS), + ], + ) + def test_constructor_kwargs_hold_none_of_them( + self, provider: str, forwarded_keys: frozenset[str] + ) -> None: + kwargs = _model_constructor_kwargs( + self._config(provider, {**NEVER_FORWARDED_BAG, "temperature": 0.2}), + "fallback", + forwarded_keys, + ) + assert kwargs == {"model": "configured-model", "temperature": 0.2} + + +class TestModelKwargsCannotSmuggleRequestKeys: + """``ChatOpenAI`` merges ``model_kwargs`` straight into the request payload, so forwarding it + would carry ``extra_headers``/``extra_query`` past every exclusion.""" + + _SMUGGLED: ClassVar[dict[str, Any]] = { + "model_kwargs": { + "extra_headers": {"X-Smuggled": "1"}, + "extra_query": {"smuggled": "1"}, + } + } + + def _payload(self, **kwargs: Any) -> dict[str, Any]: + from langchain_core.messages import HumanMessage + + model = langchain_openai.ChatOpenAI(api_key="sk-test-not-a-real-key", **kwargs) + payload: dict[str, Any] = model._get_request_payload([HumanMessage("hi")]) + return payload + + def test_model_kwargs_would_reach_the_payload_if_forwarded(self) -> None: + """The control: passed to ``ChatOpenAI`` directly, both keys reach the payload.""" + payload = self._payload(model="gpt-4o", **self._SMUGGLED) + assert payload["extra_headers"] == {"X-Smuggled": "1"} + assert payload["extra_query"] == {"smuggled": "1"} + + def test_forwarded_model_parameters_keep_them_out_of_the_payload(self) -> None: + kwargs = _model_constructor_kwargs( + { + "model": {"name": "gpt-4o", "parameters": dict(self._SMUGGLED)}, + "provider": {"name": "openai"}, + }, + "fallback", + _CHAT_OPENAI_FORWARDED_KEYS, + ) + payload = self._payload(**kwargs) + assert "extra_headers" not in payload + assert "extra_query" not in payload From 5a43f727004c2e7f4922ee3b42b3f19b3071214f Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:48:56 -0400 Subject: [PATCH 08/26] fix(claude-messages): forward the same keys on invoke and stream output_format is only accepted by messages.stream, so a config with it behaved differently depending on the call. It is now dropped on both paths; output_config, which both accept, carries the output format. One forwarded list now serves both calls. inference_geo is excluded too: it sets the region inference runs in. The loops that popped handler-owned keys are removed, since none of those keys is on the forwarded list. --- .../handler.py | 36 ++++---- .../claude-messages/tests/test_handler.py | 83 +++++++++++++++++++ .../tests/test_parameter_forwarding.py | 65 ++++++++------- 3 files changed, 133 insertions(+), 51 deletions(-) diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index 2de33466..21c51146 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -45,19 +45,17 @@ ) #: Every key ``AsyncMessages.create``/``.stream`` accept, classified by hand into exactly one of: -#: forwarded (below), handler-owned (``model``, ``messages``, ``system``, ``tools``, popped after -#: the filter runs, at each call site), or excluded (below). ``TestMessagesCreateAcceptsExactlyThese -#: Keys`` / ``TestMessagesStreamAcceptsExactlyTheseKeys`` in this package's tests assert this +#: forwarded (below), handler-owned (``model``, ``messages``, ``system``, ``tools``, set by each +#: call site itself), or excluded (below). ``TestMessagesCreateAcceptsExactlyTheseKeys`` / +#: ``TestMessagesStreamAcceptsExactlyTheseKeys`` in this package's tests assert this #: classification stays exhaustive as the SDK's own signatures change. #: -#: Everything else the API accepts is forwarded, including keys that are not strictly generation -#: settings (``metadata``, ``service_tier``, ``container``, ``user_profile_id``, ``output_config``, -#: ...). -_MESSAGES_CREATE_FORWARDED_KEYS = frozenset( +#: Only model and run settings are forwarded, and the same list serves ``invoke`` and ``stream``, +#: so one config behaves the same whichever is called. +_MESSAGES_FORWARDED_KEYS = frozenset( { "cache_control", "container", - "inference_geo", "max_tokens", "metadata", "output_config", @@ -72,20 +70,22 @@ } ) -#: Same as :data:`_MESSAGES_CREATE_FORWARDED_KEYS`, plus ``output_format``: ``.stream`` accepts it, -#: ``.create`` does not. -_MESSAGES_STREAM_FORWARDED_KEYS = _MESSAGES_CREATE_FORWARDED_KEYS | {"output_format"} - #: Accepted by the API but never forwarded, and why: #: * ``stream``: the handler chooses blocking vs. streaming itself, not via a kwarg. +#: * ``output_format``: only ``.stream`` accepts it, so forwarding it would make ``invoke`` and +#: ``stream`` behave differently. ``output_config`` (forwarded) carries the same output format +#: on both. +#: * ``inference_geo``: the region inference runs in, which decides where data is processed. #: * ``timeout``, ``extra_headers``, ``extra_query``, ``extra_body``: client/connection #: configuration (a request timeout, raw HTTP overrides), never a config-controlled setting. #: -#: Named for the drift test and for review, not read at runtime: the forwarded lists above already -#: leave these out, so nothing needs to subtract them again. +#: Named for the drift test and for review, not read at runtime: the forwarded list above already +#: leaves these out, so nothing needs to subtract them again. _MESSAGES_EXCLUDED_KEYS = frozenset( { "stream", + "output_format", + "inference_geo", "timeout", "extra_headers", "extra_query", @@ -270,11 +270,9 @@ async def _run_tool_loop( tools = _build_tools(config.get("tools") or {}) extra_params = select_forwarded_parameters( _rename_effort_to_output_config(model_parameters(config)), - _MESSAGES_CREATE_FORWARDED_KEYS, + _MESSAGES_FORWARDED_KEYS, ) max_tokens = extra_params.pop("max_tokens", 1024) - for _owned_key in ("model", "messages", "system", "tools"): - extra_params.pop(_owned_key, None) conversation = list(messages) output = "" steps = 0 @@ -568,11 +566,9 @@ async def _stream_gen( tool_definitions = to_tool_definitions(tools) extra_params = select_forwarded_parameters( _rename_effort_to_output_config(model_parameters(config)), - _MESSAGES_STREAM_FORWARDED_KEYS, + _MESSAGES_FORWARDED_KEYS, ) max_tokens = extra_params.pop("max_tokens", 1024) - for _owned_key in ("model", "messages", "system", "tools"): - extra_params.pop(_owned_key, None) conversation = list(messages) full_output = "" steps = 0 diff --git a/packages/claude-messages/tests/test_handler.py b/packages/claude-messages/tests/test_handler.py index 86c777cc..d7331007 100644 --- a/packages/claude-messages/tests/test_handler.py +++ b/packages/claude-messages/tests/test_handler.py @@ -16,6 +16,12 @@ import httpx import pytest +from tests.never_forwarded import ( + NEVER_FORWARDED_BAG, + NEVER_FORWARDED_KEYS, + find_leaks, +) + # --------------------------------------------------------------------------- # Fake anthropic response helpers # --------------------------------------------------------------------------- @@ -2543,3 +2549,80 @@ def _explode_on_the_chat_span(span: Any, capture: bool, **kw: Any) -> None: assert chat.ended == 1 assert StatusCode.ERROR in chat.statuses assert "launchdarkly.stream.abandoned" not in chat.attributes + + +class TestNeverForwardedParameters: + """No credential, endpoint, request-injection, remote-tool, or host-process key in + ``model.parameters`` reaches ``messages.create`` or ``messages.stream``.""" + + async def test_invoke_forwards_none_of_them( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": dict(NEVER_FORWARDED_BAG)}, + } + await create_claude_messages_handler()(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert not find_leaks(call_kwargs) + assert not set(call_kwargs) & NEVER_FORWARDED_KEYS + + async def test_stream_forwards_none_of_them( + self, mock_anthropic: MagicMock + ) -> None: + import launchdarkly_ai_claude_messages.spans as spans_mod + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + ctx, _ = _make_stream_context(["hi"]) + mock_anthropic.messages.stream = MagicMock(return_value=ctx) + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": dict(NEVER_FORWARDED_BAG)}, + } + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_claude_messages_handler() + async for _event in await h.stream(config, "q", {}, {}): + pass + call_kwargs = mock_anthropic.messages.stream.call_args.kwargs + assert not find_leaks(call_kwargs) + assert not set(call_kwargs) & NEVER_FORWARDED_KEYS + + +class TestInvokeAndStreamForwardTheSameKeys: + """``output_format`` is accepted by ``messages.stream`` only. Forwarding it there and not on + ``create`` made one config behave differently by call, so it is dropped on both, and + ``output_config`` (accepted by both) carries the output format instead.""" + + _PARAMS: ClassVar[dict[str, Any]] = { + "output_format": {"type": "json_schema", "schema": {}}, + "output_config": {"format": {"type": "json_schema", "schema": {}}}, + "top_p": 0.4, + } + + async def test_invoke(self, mock_anthropic: MagicMock) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = {**CONFIG, "model": {**CONFIG["model"], "parameters": self._PARAMS}} + await create_claude_messages_handler()(config, "q", {}, {}) + call_kwargs = mock_anthropic.messages.create.call_args.kwargs + assert "output_format" not in call_kwargs + assert call_kwargs["output_config"] == self._PARAMS["output_config"] + assert call_kwargs["top_p"] == 0.4 + + async def test_stream(self, mock_anthropic: MagicMock) -> None: + import launchdarkly_ai_claude_messages.spans as spans_mod + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + ctx, _ = _make_stream_context(["hi"]) + mock_anthropic.messages.stream = MagicMock(return_value=ctx) + config = {**CONFIG, "model": {**CONFIG["model"], "parameters": self._PARAMS}} + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_claude_messages_handler() + async for _event in await h.stream(config, "q", {}, {}): + pass + call_kwargs = mock_anthropic.messages.stream.call_args.kwargs + assert "output_format" not in call_kwargs + assert call_kwargs["output_config"] == self._PARAMS["output_config"] + assert call_kwargs["top_p"] == 0.4 diff --git a/packages/claude-messages/tests/test_parameter_forwarding.py b/packages/claude-messages/tests/test_parameter_forwarding.py index 18f3c242..5c6a33b8 100644 --- a/packages/claude-messages/tests/test_parameter_forwarding.py +++ b/packages/claude-messages/tests/test_parameter_forwarding.py @@ -1,12 +1,12 @@ """ Drift test for the Anthropic Messages API parameter classification in ``handler.py``. -``_MESSAGES_CREATE_FORWARDED_KEYS`` / ``_MESSAGES_STREAM_FORWARDED_KEYS`` are literal, -hand-maintained lists (see the module docstring there for why). This test is what keeps them -honest: it reads ``AsyncMessages.create``/``.stream``'s own signature and asserts every parameter -they accept is classified in exactly one of forwarded, handler-owned, or excluded, so an SDK -parameter nobody has classified yet fails loudly by name, and so does a list entry that is not a -real SDK parameter. +``_MESSAGES_FORWARDED_KEYS`` is a literal, hand-maintained list, and the same list serves ``invoke`` and ``stream``. +This test reads ``AsyncMessages.create``/``.stream``'s own signatures and asserts every parameter either +accepts is classified in exactly one of forwarded, handler-owned, or excluded, so an SDK parameter +nobody has classified yet fails loudly by name. It also asserts every forwarded or handler-owned key +is accepted by both calls, so ``invoke`` and ``stream`` cannot drift apart again, and that every +excluded key is a real parameter of at least one of them. """ from __future__ import annotations @@ -14,15 +14,14 @@ import inspect from collections.abc import Callable -import anthropic +from anthropic.resources.messages import AsyncMessages from launchdarkly_ai_claude_messages.handler import ( - _MESSAGES_CREATE_FORWARDED_KEYS, _MESSAGES_EXCLUDED_KEYS, - _MESSAGES_STREAM_FORWARDED_KEYS, + _MESSAGES_FORWARDED_KEYS, ) -#: Handler-owned: always popped from the filtered params before the call, at both call sites. +#: Handler-owned: set by each call site itself, never taken from the config. _OWNED_KEYS = frozenset({"model", "messages", "system", "tools"}) @@ -35,55 +34,59 @@ def _signature_keys(fn: Callable[..., object]) -> frozenset[str]: f"{fn!r} now accepts **{name}; this test can no longer enumerate its accept-set" ) keys.add(name) - return keys + return frozenset(keys) + + +_CREATE_KEYS = _signature_keys(AsyncMessages.create) +_STREAM_KEYS = _signature_keys(AsyncMessages.stream) class TestMessagesCreateAcceptsExactlyTheseKeys: def test_every_accepted_key_is_classified_exactly_once(self) -> None: - accepted = _signature_keys(anthropic.resources.messages.AsyncMessages.create) - classified = ( - _MESSAGES_CREATE_FORWARDED_KEYS | _OWNED_KEYS | _MESSAGES_EXCLUDED_KEYS - ) + classified = _MESSAGES_FORWARDED_KEYS | _OWNED_KEYS | _MESSAGES_EXCLUDED_KEYS - unclassified = accepted - classified + unclassified = _CREATE_KEYS - classified assert not unclassified, ( f"AsyncMessages.create now accepts {sorted(unclassified)}, not classified as " "forwarded, handler-owned, or excluded in claude-messages handler.py" ) overlap = ( - (_MESSAGES_CREATE_FORWARDED_KEYS & _OWNED_KEYS) - | (_MESSAGES_CREATE_FORWARDED_KEYS & _MESSAGES_EXCLUDED_KEYS) + (_MESSAGES_FORWARDED_KEYS & _OWNED_KEYS) + | (_MESSAGES_FORWARDED_KEYS & _MESSAGES_EXCLUDED_KEYS) | (_OWNED_KEYS & _MESSAGES_EXCLUDED_KEYS) ) assert not overlap, f"keys classified more than once: {sorted(overlap)}" - def test_every_classified_key_is_a_real_parameter(self) -> None: - accepted = _signature_keys(anthropic.resources.messages.AsyncMessages.create) - stale = (_MESSAGES_CREATE_FORWARDED_KEYS | _OWNED_KEYS) - accepted + def test_every_forwarded_or_owned_key_is_a_real_parameter(self) -> None: + stale = (_MESSAGES_FORWARDED_KEYS | _OWNED_KEYS) - _CREATE_KEYS assert not stale, ( - f"{sorted(stale)} classified in claude-messages handler.py but " + f"{sorted(stale)} forwarded or handler-owned in claude-messages handler.py but " "AsyncMessages.create does not accept them" ) class TestMessagesStreamAcceptsExactlyTheseKeys: def test_every_accepted_key_is_classified_exactly_once(self) -> None: - accepted = _signature_keys(anthropic.resources.messages.AsyncMessages.stream) - classified = ( - _MESSAGES_STREAM_FORWARDED_KEYS | _OWNED_KEYS | _MESSAGES_EXCLUDED_KEYS - ) + classified = _MESSAGES_FORWARDED_KEYS | _OWNED_KEYS | _MESSAGES_EXCLUDED_KEYS - unclassified = accepted - classified + unclassified = _STREAM_KEYS - classified assert not unclassified, ( f"AsyncMessages.stream now accepts {sorted(unclassified)}, not classified as " "forwarded, handler-owned, or excluded in claude-messages handler.py" ) - def test_every_classified_key_is_a_real_parameter(self) -> None: - accepted = _signature_keys(anthropic.resources.messages.AsyncMessages.stream) - stale = (_MESSAGES_STREAM_FORWARDED_KEYS | _OWNED_KEYS) - accepted + def test_every_forwarded_or_owned_key_is_a_real_parameter(self) -> None: + stale = (_MESSAGES_FORWARDED_KEYS | _OWNED_KEYS) - _STREAM_KEYS assert not stale, ( - f"{sorted(stale)} classified in claude-messages handler.py but " + f"{sorted(stale)} forwarded or handler-owned in claude-messages handler.py but " "AsyncMessages.stream does not accept them" ) + + +def test_every_excluded_key_is_a_real_parameter() -> None: + stale = _MESSAGES_EXCLUDED_KEYS - (_CREATE_KEYS | _STREAM_KEYS) + assert not stale, ( + f"{sorted(stale)} excluded in claude-messages handler.py but neither " + "AsyncMessages.create nor .stream accepts them" + ) From 5314cb54288a8cc4a843dd29318c077ced2f18d0 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:48:56 -0400 Subject: [PATCH 09/26] fix(openai-messages): forward the same keys on invoke and stream response_id, starting_after and text_format are only accepted by responses.stream. The first two resume an existing response and the third names a Python type to parse into, so none of them is a setting a config should carry. They are now dropped on both paths, and one forwarded list serves both calls. The UI writes reasoning effort as reasoning.effort, the Responses API's own shape, so reasoning is forwarded as-is and no reasoning_effort mapping is needed. The loops that popped handler-owned keys are removed, since none of those keys is on the forwarded list. --- .../handler.py | 75 +++++++----------- .../openai-messages/tests/test_handler.py | 79 +++++++++++++++++++ .../tests/test_parameter_forwarding.py | 62 ++++++++------- 3 files changed, 142 insertions(+), 74 deletions(-) diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index 6c04012e..d84a52ab 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -50,28 +50,21 @@ #: Every key ``AsyncResponses.create``/``.stream`` accept, classified by hand into exactly one of: #: forwarded (below), handler-owned (``model``, ``input``, ``previous_response_id``, ``tools``, -#: ``text``, popped after the filter runs, at each call site), or excluded. +#: ``text``, set by each call site itself), or excluded (below). #: ``TestResponsesCreateAcceptsExactlyTheseKeys`` / ``TestResponsesStreamAcceptsExactlyTheseKeys`` #: in this package's tests assert this classification stays exhaustive as the SDK's own signatures -#: change. Confirms the UI offers several keys the Responses API has never accepted: ``max_tokens``, +#: change. The UI offers several keys the Responses API has never accepted: ``max_tokens``, #: ``frequency_penalty``, ``presence_penalty``, ``seed``, ``n``, ``stop``, ``response_format``, #: ``logit_bias``, ``logprobs``, ``max_completion_tokens``, ``audio``, ``modalities``, -#: ``prediction``. +#: ``prediction``. Of those, ``max_tokens``/``max_completion_tokens`` are renamed to +#: ``max_output_tokens`` (see :func:`_apply_max_output_tokens_rename`); the rest are dropped. #: -#: Excluded, and why: -#: * ``stream``: the handler chooses blocking vs. streaming itself, not via a kwarg. -#: * ``stream_options``: only meaningful together with ``stream=True``, which the handler controls. -#: * ``background``: returns before the output exists, so the handler would get no result. -#: * ``conversation``: server-side conversation state conflicts with the input the handler builds. -#: * ``prompt``: server-side prompt template conflicts with the input the handler builds. -#: * ``timeout``, ``extra_headers``, ``extra_query``, ``extra_body``: client/connection -#: configuration (a request timeout, raw HTTP overrides), never a config-controlled setting. +#: The UI writes reasoning effort as ``reasoning: {"effort": ...}``, the Responses API's own shape, +#: so ``reasoning`` is forwarded as-is and no top-level ``reasoning_effort`` is read. #: -#: Everything else the API accepts is forwarded, including keys that are not strictly generation -#: settings (``store``, ``user``, ``safety_identifier``, ``prompt_cache_key``, -#: ``prompt_cache_retention``, ``include``, ``context_management``, ``metadata``, -#: ``service_tier``, ``instructions``, ``moderation``, ...). -_RESPONSES_CREATE_FORWARDED_KEYS = frozenset( +#: Only model and run settings are forwarded, and the same list serves ``invoke`` and ``stream``, +#: so one config behaves the same whichever is called. +_RESPONSES_FORWARDED_KEYS = frozenset( { "context_management", "include", @@ -96,17 +89,22 @@ } ) -#: Same as :data:`_RESPONSES_CREATE_FORWARDED_KEYS`, plus the resumption keys only ``.stream`` -#: accepts: ``response_id``, ``starting_after``, ``text_format``. -_RESPONSES_STREAM_FORWARDED_KEYS = _RESPONSES_CREATE_FORWARDED_KEYS | { - "response_id", - "starting_after", - "text_format", -} - -#: Named for the drift test and for review, not read at runtime: the forwarded lists above already -#: leave these out, so nothing needs to subtract them again. See the comment above for why each one -#: is here rather than forwarded. +#: Accepted by the API but never forwarded, and why: +#: * ``stream``: the handler chooses blocking vs. streaming itself, not via a kwarg. +#: * ``stream_options``: only meaningful together with ``stream=True``, which the handler controls. +#: * ``background``: returns before the output exists, so the handler would get no result. +#: * ``conversation``: server-side conversation state conflicts with the input the handler builds. +#: * ``prompt``: server-side prompt template conflicts with the input the handler builds. +#: * ``response_id``, ``starting_after``: only ``.stream`` accepts them, to resume an existing +#: response rather than start a new one. That is request state, not a setting, and ``invoke`` has +#: no equivalent. +#: * ``text_format``: only ``.stream`` accepts it, and it is a Python type to parse into, which a +#: config cannot express. Structured output goes through ``text``, which the handler owns. +#: * ``timeout``, ``extra_headers``, ``extra_query``, ``extra_body``: client/connection +#: configuration (a request timeout, raw HTTP overrides), never a config-controlled setting. +#: +#: Named for the drift test and for review, not read at runtime: the forwarded list above already +#: leaves these out, so nothing needs to subtract them again. _RESPONSES_EXCLUDED_KEYS = frozenset( { "stream", @@ -114,6 +112,9 @@ "background", "conversation", "prompt", + "response_id", + "starting_after", + "text_format", "timeout", "extra_headers", "extra_query", @@ -355,16 +356,8 @@ async def _call_impl( extra_params = select_forwarded_parameters( _apply_max_output_tokens_rename(model_parameters(config)), - _RESPONSES_CREATE_FORWARDED_KEYS, + _RESPONSES_FORWARDED_KEYS, ) - for _owned_key in ( - "model", - "input", - "previous_response_id", - "tools", - "text", - ): - extra_params.pop(_owned_key, None) params: dict[str, Any] = { **extra_params, "model": config["model"]["name"], @@ -597,16 +590,8 @@ async def _stream_gen( extra_params = select_forwarded_parameters( _apply_max_output_tokens_rename(model_parameters(config)), - _RESPONSES_STREAM_FORWARDED_KEYS, + _RESPONSES_FORWARDED_KEYS, ) - for _owned_key in ( - "model", - "input", - "previous_response_id", - "tools", - "text", - ): - extra_params.pop(_owned_key, None) stream_params: dict[str, Any] = { **extra_params, "model": config["model"]["name"], diff --git a/packages/openai-messages/tests/test_handler.py b/packages/openai-messages/tests/test_handler.py index c0902cad..813edb55 100644 --- a/packages/openai-messages/tests/test_handler.py +++ b/packages/openai-messages/tests/test_handler.py @@ -15,6 +15,12 @@ import httpx import pytest +from tests.never_forwarded import ( + NEVER_FORWARDED_BAG, + NEVER_FORWARDED_KEYS, + find_leaks, +) + CONFIG = { "model": {"name": "gpt-4o"}, "provider": {"name": "OpenAI"}, @@ -2514,3 +2520,76 @@ async def test_a_reasoning_item_with_no_summary_is_dropped( chat = rec.named("chat ")[0] assert chat.attributes["gen_ai.completion.0.content"] == "the real answer" assert "gen_ai.completion.1.content" not in chat.attributes + + +class TestNeverForwardedParameters: + """No credential, endpoint, request-injection, remote-tool, or host-process key in + ``model.parameters`` reaches ``responses.create`` or ``responses.stream``.""" + + async def test_invoke_forwards_none_of_them(self, mock_openai: MagicMock) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": dict(NEVER_FORWARDED_BAG)}, + } + await create_openai_messages_handler()(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert not find_leaks(kwargs) + assert not set(kwargs) & NEVER_FORWARDED_KEYS + + async def test_stream_forwards_none_of_them(self, mock_openai: MagicMock) -> None: + import launchdarkly_ai_openai_messages.spans as spans_mod + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + mock_openai.responses.stream = MagicMock( + return_value=_make_openai_stream_context(["hi"]) + ) + config = { + **CONFIG, + "model": {**CONFIG["model"], "parameters": dict(NEVER_FORWARDED_BAG)}, + } + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_openai_messages_handler() + [e async for e in await h.stream(config, "q")] + kwargs = mock_openai.responses.stream.call_args.kwargs + assert not find_leaks(kwargs) + assert not set(kwargs) & NEVER_FORWARDED_KEYS + + +class TestInvokeAndStreamForwardTheSameKeys: + """``response_id``, ``starting_after`` and ``text_format`` are accepted by + ``responses.stream`` only. Forwarding them there and not on ``create`` made one config behave + differently by call. They resume an existing response or name a Python type to parse into, + neither of which is a setting, so both paths drop them.""" + + _PARAMS: ClassVar[dict[str, Any]] = { + "response_id": "resp-other", + "starting_after": 3, + "text_format": "not-a-type", + "top_p": 0.4, + } + + async def test_invoke(self, mock_openai: MagicMock) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + config = {**CONFIG, "model": {**CONFIG["model"], "parameters": self._PARAMS}} + await create_openai_messages_handler()(config, "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert not {"response_id", "starting_after", "text_format"} & set(kwargs) + assert kwargs["top_p"] == 0.4 + + async def test_stream(self, mock_openai: MagicMock) -> None: + import launchdarkly_ai_openai_messages.spans as spans_mod + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + mock_openai.responses.stream = MagicMock( + return_value=_make_openai_stream_context(["hi"]) + ) + config = {**CONFIG, "model": {**CONFIG["model"], "parameters": self._PARAMS}} + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_openai_messages_handler() + [e async for e in await h.stream(config, "q")] + kwargs = mock_openai.responses.stream.call_args.kwargs + assert not {"response_id", "starting_after", "text_format"} & set(kwargs) + assert kwargs["top_p"] == 0.4 diff --git a/packages/openai-messages/tests/test_parameter_forwarding.py b/packages/openai-messages/tests/test_parameter_forwarding.py index a4b562a2..dc512c51 100644 --- a/packages/openai-messages/tests/test_parameter_forwarding.py +++ b/packages/openai-messages/tests/test_parameter_forwarding.py @@ -1,11 +1,12 @@ """ Drift test for the OpenAI Responses API parameter classification in ``handler.py``. -``_RESPONSES_CREATE_FORWARDED_KEYS`` / ``_RESPONSES_STREAM_FORWARDED_KEYS`` are literal, -hand-maintained lists. This test reads ``AsyncResponses.create``/``.stream``'s own signature and -asserts every parameter they accept is classified in exactly one of forwarded, handler-owned, or -excluded, so an SDK parameter nobody has classified yet fails loudly by name, and so does a list -entry that is not a real SDK parameter. +``_RESPONSES_FORWARDED_KEYS`` is a literal, hand-maintained list, and the same list serves ``invoke`` and ``stream``. +This test reads ``AsyncResponses.create``/``.stream``'s own signatures and asserts every parameter either +accepts is classified in exactly one of forwarded, handler-owned, or excluded, so an SDK parameter +nobody has classified yet fails loudly by name. It also asserts every forwarded or handler-owned key +is accepted by both calls, so ``invoke`` and ``stream`` cannot drift apart again, and that every +excluded key is a real parameter of at least one of them. """ from __future__ import annotations @@ -16,12 +17,11 @@ from openai.resources.responses import AsyncResponses from launchdarkly_ai_openai_messages.handler import ( - _RESPONSES_CREATE_FORWARDED_KEYS, _RESPONSES_EXCLUDED_KEYS, - _RESPONSES_STREAM_FORWARDED_KEYS, + _RESPONSES_FORWARDED_KEYS, ) -#: Handler-owned: always popped from the filtered params before the call, at both call sites. +#: Handler-owned: set by each call site itself, never taken from the config. _OWNED_KEYS = frozenset({"model", "input", "previous_response_id", "tools", "text"}) @@ -34,55 +34,59 @@ def _signature_keys(fn: Callable[..., object]) -> frozenset[str]: f"{fn!r} now accepts **{name}; this test can no longer enumerate its accept-set" ) keys.add(name) - return keys + return frozenset(keys) + + +_CREATE_KEYS = _signature_keys(AsyncResponses.create) +_STREAM_KEYS = _signature_keys(AsyncResponses.stream) class TestResponsesCreateAcceptsExactlyTheseKeys: def test_every_accepted_key_is_classified_exactly_once(self) -> None: - accepted = _signature_keys(AsyncResponses.create) - classified = ( - _RESPONSES_CREATE_FORWARDED_KEYS | _OWNED_KEYS | _RESPONSES_EXCLUDED_KEYS - ) + classified = _RESPONSES_FORWARDED_KEYS | _OWNED_KEYS | _RESPONSES_EXCLUDED_KEYS - unclassified = accepted - classified + unclassified = _CREATE_KEYS - classified assert not unclassified, ( f"AsyncResponses.create now accepts {sorted(unclassified)}, not classified as " "forwarded, handler-owned, or excluded in openai-messages handler.py" ) overlap = ( - (_RESPONSES_CREATE_FORWARDED_KEYS & _OWNED_KEYS) - | (_RESPONSES_CREATE_FORWARDED_KEYS & _RESPONSES_EXCLUDED_KEYS) + (_RESPONSES_FORWARDED_KEYS & _OWNED_KEYS) + | (_RESPONSES_FORWARDED_KEYS & _RESPONSES_EXCLUDED_KEYS) | (_OWNED_KEYS & _RESPONSES_EXCLUDED_KEYS) ) assert not overlap, f"keys classified more than once: {sorted(overlap)}" - def test_every_classified_key_is_a_real_parameter(self) -> None: - accepted = _signature_keys(AsyncResponses.create) - stale = (_RESPONSES_CREATE_FORWARDED_KEYS | _OWNED_KEYS) - accepted + def test_every_forwarded_or_owned_key_is_a_real_parameter(self) -> None: + stale = (_RESPONSES_FORWARDED_KEYS | _OWNED_KEYS) - _CREATE_KEYS assert not stale, ( - f"{sorted(stale)} classified in openai-messages handler.py but " + f"{sorted(stale)} forwarded or handler-owned in openai-messages handler.py but " "AsyncResponses.create does not accept them" ) class TestResponsesStreamAcceptsExactlyTheseKeys: def test_every_accepted_key_is_classified_exactly_once(self) -> None: - accepted = _signature_keys(AsyncResponses.stream) - classified = ( - _RESPONSES_STREAM_FORWARDED_KEYS | _OWNED_KEYS | _RESPONSES_EXCLUDED_KEYS - ) + classified = _RESPONSES_FORWARDED_KEYS | _OWNED_KEYS | _RESPONSES_EXCLUDED_KEYS - unclassified = accepted - classified + unclassified = _STREAM_KEYS - classified assert not unclassified, ( f"AsyncResponses.stream now accepts {sorted(unclassified)}, not classified as " "forwarded, handler-owned, or excluded in openai-messages handler.py" ) - def test_every_classified_key_is_a_real_parameter(self) -> None: - accepted = _signature_keys(AsyncResponses.stream) - stale = (_RESPONSES_STREAM_FORWARDED_KEYS | _OWNED_KEYS) - accepted + def test_every_forwarded_or_owned_key_is_a_real_parameter(self) -> None: + stale = (_RESPONSES_FORWARDED_KEYS | _OWNED_KEYS) - _STREAM_KEYS assert not stale, ( - f"{sorted(stale)} classified in openai-messages handler.py but " + f"{sorted(stale)} forwarded or handler-owned in openai-messages handler.py but " "AsyncResponses.stream does not accept them" ) + + +def test_every_excluded_key_is_a_real_parameter() -> None: + stale = _RESPONSES_EXCLUDED_KEYS - (_CREATE_KEYS | _STREAM_KEYS) + assert not stale, ( + f"{sorted(stale)} excluded in openai-messages handler.py but neither " + "AsyncResponses.create nor .stream accepts them" + ) From d681399906bfc692c817dc42bd41d41ad7801801 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:48:56 -0400 Subject: [PATCH 10/26] refactor(openai-agents): drop the dead max_turns pop max_turns is not a ModelSettings field, so the forwarded-keys filter already drops it. The native graph now says why only the root node's max_turns applies: the whole graph is one Runner.run, and the Agents SDK has no per-agent turn limit. --- .../launchdarkly_ai_openai_agents/handler.py | 9 ++- .../native_graph.py | 9 +-- packages/openai-agents/tests/test_handler.py | 55 +++++++++++++++++++ .../openai-agents/tests/test_native_graph.py | 41 ++++++++++++++ 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index c856f646..a2ff32d1 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -76,7 +76,7 @@ #: Every field ``agents.ModelSettings`` declares, classified by hand into exactly one of: forwarded #: (below) or excluded. ``ModelSettings`` has no handler-owned fields: ``model`` and ``max_turns`` -#: live outside it (on ``Agent``/``Runner.run``, see the ``.pop("max_turns", ...)`` below). +#: live outside it (on ``Agent``/``Runner.run``; ``max_turns`` is read separately for the run). #: ``TestModelSettingsAcceptsExactlyTheseFields`` in this package's tests asserts this #: classification stays exhaustive as the SDK's own dataclass changes. #: @@ -253,11 +253,10 @@ def _build_agent_and_prompt( # change, not a telemetry one, so it is left alone. `_call_impl` still returns the parsed # `final_output` object as-is when `outputFormat` is configured, matching the pre-existing # return-shape contract. - model_settings_params = model_parameters(config) - # `max_turns` is a `Runner.run` option, not a `ModelSettings` field. - model_settings_params.pop("max_turns", None) + # `max_turns` is a `Runner.run` option, not a `ModelSettings` field, so the filter drops it here + # and the run call reads it separately. model_settings_params = select_forwarded_parameters( - model_settings_params, _MODEL_SETTINGS_FORWARDED_KEYS + model_parameters(config), _MODEL_SETTINGS_FORWARDED_KEYS ) agent = Agent( name="assistant", diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index 7d020cf4..7ec0bde9 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -177,11 +177,8 @@ async def _visit(node_key: str) -> None: agent_name = _sanitize_name(node.key) agent_name_to_key[agent_name] = node.key - node_model_settings_params = model_parameters(node.config) - # `max_turns` is a `Runner.run` option, not a `ModelSettings` field. - node_model_settings_params.pop("max_turns", None) node_model_settings_params = select_forwarded_parameters( - node_model_settings_params, _MODEL_SETTINGS_FORWARDED_KEYS + model_parameters(node.config), _MODEL_SETTINGS_FORWARDED_KEYS ) agent = Agent( name=agent_name, @@ -278,6 +275,10 @@ async def on_agent_start(self, context: Any, agent: Any) -> None: root_prompt = _to_openai_agent_items(turns) try: + # Only the root node's `max_turns` applies. The whole graph is one `Runner.run`, and + # the Agents SDK counts turns once for that run, across every handoff: `max_turns` is + # a `Runner.run` argument, and `Agent` has no per-agent turn limit to set from a + # child node's config. root_max_turns = model_parameters(root.config).get("max_turns") root_run_kwargs = ( {"max_turns": root_max_turns} if root_max_turns is not None else {} diff --git a/packages/openai-agents/tests/test_handler.py b/packages/openai-agents/tests/test_handler.py index 8743794e..ab950f81 100644 --- a/packages/openai-agents/tests/test_handler.py +++ b/packages/openai-agents/tests/test_handler.py @@ -27,6 +27,7 @@ openai_agents, ) from launchdarkly_ai_openai_agents.utils import build_output_type +from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks # --------------------------------------------------------------------------- # Fake `agents` SDK @@ -2254,3 +2255,57 @@ async def _drain() -> None: assert rec.root.attributes.get("launchdarkly.run.cancelled") is True assert "launchdarkly.stream.abandoned" not in rec.root.attributes + + +class TestNeverForwardedParameters: + """No credential, endpoint, request-injection, remote-tool, or host-process key in + ``model.parameters`` reaches ``ModelSettings`` or the ``Runner`` call.""" + + async def test_invoke_forwards_none_of_them(self) -> None: + captured: dict[str, Any] = {} + + async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + captured["agent"] = agent + captured["run_kwargs"] = kw + await _drive_turns(hooks, agent, prompt, [{"output": _text_output("hi")}]) + return FakeRunResult("done") + + agents_mod = _fake_agents_module(run=run) + config = _make_config( + instructions="Be helpful.", + model={ + "name": "gpt-4o", + "parameters": {**NEVER_FORWARDED_BAG, "temperature": 0.1}, + }, + ) + with _patched_agents(agents_mod): + await create_openai_agent_handler()(config, "q", {}, {}) + model_settings = captured["agent"].kwargs["model_settings"] + assert model_settings.kwargs == {"temperature": 0.1} + assert not find_leaks(captured["run_kwargs"]) + + async def test_stream_forwards_none_of_them(self) -> None: + captured: dict[str, Any] = {} + + def run_streamed(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + captured["agent"] = agent + captured["run_kwargs"] = kw + return FakeStreamedResult( + agent, prompt, hooks, [{"output": _text_output("hi")}], "done" + ) + + agents_mod = _fake_agents_module(run_streamed=run_streamed) + config = _make_config( + instructions="Be helpful.", + model={ + "name": "gpt-4o", + "parameters": {**NEVER_FORWARDED_BAG, "temperature": 0.1}, + }, + ) + with _patched_agents(agents_mod): + gen = await create_openai_agent_handler().stream(config, "q", {}, {}) + async for _event in gen: + pass + model_settings = captured["agent"].kwargs["model_settings"] + assert model_settings.kwargs == {"temperature": 0.1} + assert not find_leaks(captured["run_kwargs"]) diff --git a/packages/openai-agents/tests/test_native_graph.py b/packages/openai-agents/tests/test_native_graph.py index 2df4696f..aa731465 100644 --- a/packages/openai-agents/tests/test_native_graph.py +++ b/packages/openai-agents/tests/test_native_graph.py @@ -13,6 +13,7 @@ import launchdarkly_ai_openai_agents.native_graph as _openai_ng from launchdarkly_ai_openai_agents.native_graph import to_openai_agents from launchdarkly_ai_server import GraphDefinition, GraphEdge, GraphNode +from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks # --------------------------------------------------------------------------- # Helpers @@ -761,3 +762,43 @@ async def _run_and_fire_hook(agent: Any, text: str, hooks: Any = None) -> Any: assert captured_hooks, "hooks were not passed to Runner.run" assert "$ld:ai:graph:handoff_success" in track_calls + + +class TestNativeGraphModelParameters: + @pytest.mark.asyncio + async def test_no_never_forwarded_key_reaches_any_agent_or_the_run(self) -> None: + run_result = _make_run_result("out") + agents_mock = _make_agents_mock(run_result) + agents_mock.ModelSettings = MagicMock(side_effect=lambda **kw: dict(kw)) + nodes = { + "root": { + "key": "root", + "config": { + "model": { + "name": "gpt-4o", + "parameters": { + **NEVER_FORWARDED_BAG, + "temperature": 0.1, + "max_turns": 6, + }, + }, + "instructions": "help", + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + graph_def = _make_graph_def(nodes=nodes) + + with patch( + "importlib.import_module", + side_effect=lambda n: agents_mock if n == "agents" else __import__(n), + ): + await to_openai_agents(_make_def_promise(graph_def)).invoke("hi") + + (root_agent,) = agents_mock._created_agents + assert root_agent._kw["model_settings"] == {"temperature": 0.1} + run_kwargs = agents_mock.Runner.run.call_args.kwargs + assert run_kwargs["max_turns"] == 6 + assert not find_leaks({k: v for k, v in run_kwargs.items() if k != "hooks"}) From 4a233d70e4cfd9ec8040ff1ab64f4110b8ecfdb4 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:48:56 -0400 Subject: [PATCH 11/26] test: check every handler's forwarded lists against the never-forwarded keys One test over all six packages asserts that no forwarded list holds a credential, endpoint, request-injection, remote-tool or host-process key, and that every forwarded list in each handler module is covered. The client docstrings now say what a forwarded list may hold. --- .../parameter_forwarding.py | 9 ++- .../src/launchdarkly_ai_server/utils.py | 6 +- tests/test_never_forwarded_parameters.py | 74 +++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 tests/test_never_forwarded_parameters.py diff --git a/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py b/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py index 0fc0b320..87463e1f 100644 --- a/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py +++ b/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py @@ -4,10 +4,11 @@ ``model.parameters`` is a free-form dict the LaunchDarkly UI writes, offering keys that make sense across providers (``temperature``, ``top_p``, ``max_tokens``, ``tool_choice``, ...). No single provider SDK accepts all of them, and each handler package classifies every key its own -provider entry point accepts into exactly one written-down list: forwarded, handler-owned (popped -after this filter runs, decided per call site), or excluded (accepted by the provider but never -forwarded, either because forwarding it would break the handler or because it is client/connection -configuration such as an API key, a base URL, an HTTP client, or a timeout). Those lists are +provider entry point accepts into exactly one written-down list: forwarded (model and run settings +only), handler-owned (set by the call site itself), or excluded (accepted by the provider but never +forwarded: credentials, endpoints and connection settings, raw request injection such as extra +headers or ``model_kwargs``, remote tools, host-process settings, and anything that would break +the handler). Only the forwarded list is read at runtime; every other key is dropped. Those lists are literal, next to each handler's own call sites, not derived from the provider SDK at runtime: a hand-maintained list is reviewable and a runtime-derived one is not, and each handler package has a drift test asserting its lists still cover everything its provider SDK accepts. diff --git a/packages/client/src/launchdarkly_ai_server/utils.py b/packages/client/src/launchdarkly_ai_server/utils.py index f3cd71d3..beedc553 100644 --- a/packages/client/src/launchdarkly_ai_server/utils.py +++ b/packages/client/src/launchdarkly_ai_server/utils.py @@ -45,9 +45,9 @@ def model_parameters(config: AiConfigRep) -> dict[str, Any]: Values are forwarded to the provider as-is, keyed by whatever name the LaunchDarkly UI wrote (already snake_case for every Python provider SDK here), so no case conversion happens on the way through. Callers must - still remove any key the call site sets itself before merging the result - into a provider call, so a config value never overrides a handler-owned - argument. + filter the result to their own forwarded-keys list (see + ``parameter_forwarding.select_forwarded_parameters``) before merging it + into a provider call; nothing else here is safe to forward as-is. Never reads ``model.custom`` — that field is not forwarded to providers. """ diff --git a/tests/test_never_forwarded_parameters.py b/tests/test_never_forwarded_parameters.py new file mode 100644 index 00000000..c6f82e6c --- /dev/null +++ b/tests/test_never_forwarded_parameters.py @@ -0,0 +1,74 @@ +"""Cross-handler invariant: no handler forwards a never-forwarded key from ``model.parameters``. + +Each handler forwards only its own literal allowlist of model and run settings. This checks every +one of those lists, in all six packages, against the shared list of keys that must never be +forwarded (``never_forwarded.py``). Each package's own tests check its real call sites with the +same keys. +""" + +from __future__ import annotations + +import importlib + +import pytest + +from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters +from tests.never_forwarded import ( + NEVER_FORWARDED_BAG, + NEVER_FORWARDED_KEYS, + find_leaks, + leaked, +) + +#: Handler module to the forwarded lists it defines. Listed rather than discovered so that a list +#: renamed or added without being checked here fails below by name. +FORWARDED_LISTS: dict[str, set[str]] = { + "launchdarkly_ai_claude_agents.handler": {"_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS"}, + "launchdarkly_ai_claude_messages.handler": {"_MESSAGES_FORWARDED_KEYS"}, + "launchdarkly_ai_openai_agents.handler": {"_MODEL_SETTINGS_FORWARDED_KEYS"}, + "launchdarkly_ai_openai_messages.handler": {"_RESPONSES_FORWARDED_KEYS"}, + "launchdarkly_ai_langchain_agents.handler": { + "_CHAT_OPENAI_FORWARDED_KEYS", + "_CHAT_ANTHROPIC_FORWARDED_KEYS", + "_CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS", + }, + "launchdarkly_ai_langchain_messages.handler": { + "_CHAT_OPENAI_FORWARDED_KEYS", + "_CHAT_ANTHROPIC_FORWARDED_KEYS", + "_CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS", + }, +} + +_CASES = [ + (module, name) + for module, names in FORWARDED_LISTS.items() + for name in sorted(names) +] + + +@pytest.mark.parametrize("module", sorted(FORWARDED_LISTS)) +def test_every_forwarded_list_is_checked(module: str) -> None: + mod = importlib.import_module(module) + defined = {name for name in vars(mod) if name.endswith("_FORWARDED_KEYS")} + assert defined == FORWARDED_LISTS[module], ( + f"{module} defines forwarded lists {sorted(defined)}; " + f"this test checks {sorted(FORWARDED_LISTS[module])}" + ) + + +@pytest.mark.parametrize(("module", "name"), _CASES) +def test_no_forwarded_list_holds_a_never_forwarded_key(module: str, name: str) -> None: + forwarded: frozenset[str] = getattr(importlib.import_module(module), name) + assert not forwarded & NEVER_FORWARDED_KEYS, ( + f"{module}.{name} forwards {sorted(forwarded & NEVER_FORWARDED_KEYS)}" + ) + assert select_forwarded_parameters(NEVER_FORWARDED_BAG, forwarded) == {} + + +def test_find_leaks_finds_a_marker_at_any_depth() -> None: + class _Holder: + def __init__(self) -> None: + self.options = {"nested": [("x", leaked("cli_path"))]} + + assert find_leaks({"a": _Holder(), "b": leaked("env")}) == {"cli_path", "env"} + assert find_leaks({"a": "fine", "b": [1, 2]}) == set() From fba3d67cf44e02dbc6a79f66133c9011239ab0e6 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:57:08 -0400 Subject: [PATCH 12/26] fix: stop forwarding container state and request attribution container (Claude Messages) and reuse_last_container (ChatAnthropic) carry server-side container state over from another request, and user_profile_id attributes the request to another party. None of them is a model setting, so they move to the excluded lists. --- .../src/launchdarkly_ai_claude_messages/handler.py | 8 ++++++-- .../src/launchdarkly_ai_langchain_agents/handler.py | 4 +++- .../src/launchdarkly_ai_langchain_messages/handler.py | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index 21c51146..04fd9e49 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -55,7 +55,6 @@ _MESSAGES_FORWARDED_KEYS = frozenset( { "cache_control", - "container", "max_tokens", "metadata", "output_config", @@ -66,7 +65,6 @@ "tool_choice", "top_k", "top_p", - "user_profile_id", } ) @@ -76,6 +74,10 @@ #: ``stream`` behave differently. ``output_config`` (forwarded) carries the same output format #: on both. #: * ``inference_geo``: the region inference runs in, which decides where data is processed. +#: * ``container``: selects server-side container state carried over from another request, not +#: a model setting. +#: * ``user_profile_id``: attributes the request to a party other than the caller, an identity +#: setting rather than a model setting. #: * ``timeout``, ``extra_headers``, ``extra_query``, ``extra_body``: client/connection #: configuration (a request timeout, raw HTTP overrides), never a config-controlled setting. #: @@ -86,6 +88,8 @@ "stream", "output_format", "inference_geo", + "container", + "user_profile_id", "timeout", "extra_headers", "extra_query", 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 d00b40d7..f02b4f22 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -165,7 +165,6 @@ "max_tokens", "max_tokens_to_sample", "output_config", - "reuse_last_container", "stop", "stop_sequences", "temperature", @@ -182,6 +181,8 @@ #: * ``default_headers``, ``model_kwargs``: raw request injection. ``model_kwargs`` is merged #: straight into the request, so it would carry any excluded key past this list. #: * ``mcp_servers``: attaches remote MCP servers, which then receive the conversation. +#: * ``reuse_last_container``: reuses server-side container state from an earlier request, not +#: a model setting. #: * ``default_request_timeout``, ``timeout``, ``max_retries``: timeouts and retries. #: * ``stream_usage``: how usage is reported back to the handler. #: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. @@ -197,6 +198,7 @@ "default_headers", "model_kwargs", "mcp_servers", + "reuse_last_container", "default_request_timeout", "timeout", "max_retries", 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 daadd8f7..4bcd0b3f 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -168,7 +168,6 @@ "max_tokens", "max_tokens_to_sample", "output_config", - "reuse_last_container", "stop", "stop_sequences", "temperature", @@ -185,6 +184,8 @@ #: * ``default_headers``, ``model_kwargs``: raw request injection. ``model_kwargs`` is merged #: straight into the request, so it would carry any excluded key past this list. #: * ``mcp_servers``: attaches remote MCP servers, which then receive the conversation. +#: * ``reuse_last_container``: reuses server-side container state from an earlier request, not +#: a model setting. #: * ``default_request_timeout``, ``timeout``, ``max_retries``: timeouts and retries. #: * ``stream_usage``: how usage is reported back to the handler. #: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. @@ -200,6 +201,7 @@ "default_headers", "model_kwargs", "mcp_servers", + "reuse_last_container", "default_request_timeout", "timeout", "max_retries", From 1a1539eeb4f5a69c18f8d11ec83c42fff903e34f Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 5 Oct 2026 18:57:08 -0400 Subject: [PATCH 13/26] test(claude-agents): replace the extra_body check with the never-forwarded bag extra_body is not a ClaudeAgentOptions field, so asserting it was dropped proved nothing. The handler test now sends every never-forwarded key through query and checks none reaches the options. provider_data joins the shared list, since in newer Agents SDK releases it carries raw request overrides. --- packages/claude-agents/tests/test_handler.py | 11 ++++++++--- tests/never_forwarded.py | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index d3f7d5ca..970565fc 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -43,6 +43,7 @@ partition_tools, ) from launchdarkly_ai_server import ConversationIdSpanProcessor, conversation_id +from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks # --------------------------------------------------------------------------- # A real tracer provider, reset between tests @@ -1527,19 +1528,23 @@ async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising( ): assert not hasattr(options, rejected) or getattr(options, rejected) is None - async def test_transport_key_is_never_forwarded( + async def test_no_never_forwarded_key_reaches_the_query_options( self, monkeypatch: pytest.MonkeyPatch ) -> None: + """Every credential, endpoint, request-injection, remote-tool, and host-process key, + including the real ``ClaudeAgentOptions`` fields ``cli_path``, ``env``, ``cwd``, + ``add_dirs``, ``permission_mode`` and ``can_use_tool``, is dropped on the way to + ``query``; the agreed run setting still lands.""" config = { **BASE_CONFIG, "model": { **BASE_CONFIG["model"], - "parameters": {"extra_body": {"secret": "value"}, "max_turns": 2}, + "parameters": {**NEVER_FORWARDED_BAG, "max_turns": 2}, }, } options = await self._run_and_capture_options(config, monkeypatch) assert options.max_turns == 2 - assert not hasattr(options, "extra_body") or options.extra_body is None + assert not find_leaks(options) async def test_temperature_top_p_and_max_turns_run_without_type_error( self, monkeypatch: pytest.MonkeyPatch diff --git a/tests/never_forwarded.py b/tests/never_forwarded.py index 62c15be9..24a34e8a 100644 --- a/tests/never_forwarded.py +++ b/tests/never_forwarded.py @@ -57,6 +57,7 @@ "extra_query", "extra_args", "model_kwargs", + "provider_data", "additional_model_request_fields", # Remote tools. "mcp_servers", From 372c2ae948e4e4e282a90147770b7f4a2ac56a97 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 12:32:12 -0400 Subject: [PATCH 14/26] test: require a forwarded-keys entry for every discovered handler module Scan packages/*/src/launchdarkly_ai_* source for modules that build a ProviderHandler and fail when one has no entry in FORWARDED_LISTS, so a new handler package cannot skip the never-forwarded check. The package that defines ProviderHandler is the client core and is skipped. --- tests/test_never_forwarded_parameters.py | 87 ++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/tests/test_never_forwarded_parameters.py b/tests/test_never_forwarded_parameters.py index c6f82e6c..b329526b 100644 --- a/tests/test_never_forwarded_parameters.py +++ b/tests/test_never_forwarded_parameters.py @@ -1,14 +1,18 @@ """Cross-handler invariant: no handler forwards a never-forwarded key from ``model.parameters``. Each handler forwards only its own literal allowlist of model and run settings. This checks every -one of those lists, in all six packages, against the shared list of keys that must never be -forwarded (``never_forwarded.py``). Each package's own tests check its real call sites with the -same keys. +one of those lists against the shared list of keys that must never be forwarded +(``never_forwarded.py``). Each package's own tests check its real call sites with the same keys. + +Every handler module in ``packages/*/src/launchdarkly_ai_*`` must be listed in ``FORWARDED_LISTS``, +so a new handler package fails here until it declares an allowlist. """ from __future__ import annotations +import ast import importlib +from pathlib import Path import pytest @@ -20,8 +24,9 @@ leaked, ) -#: Handler module to the forwarded lists it defines. Listed rather than discovered so that a list -#: renamed or added without being checked here fails below by name. +#: Handler module to the forwarded lists it defines. The lists are named rather than discovered so +#: that a list renamed or added without being checked here fails below by name; the modules are +#: checked against ``discover_handler_modules`` so a new handler module must be added here. FORWARDED_LISTS: dict[str, set[str]] = { "launchdarkly_ai_claude_agents.handler": {"_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS"}, "launchdarkly_ai_claude_messages.handler": {"_MESSAGES_FORWARDED_KEYS"}, @@ -39,6 +44,78 @@ }, } +_PACKAGES_DIR = Path(__file__).resolve().parent.parent / "packages" + + +def _module_name(path: Path, src: Path) -> str: + """``src/launchdarkly_ai_x/handler.py`` to ``launchdarkly_ai_x.handler``.""" + return ".".join(path.relative_to(src).with_suffix("").parts).removesuffix( + ".__init__" + ) + + +def _provides_handler(tree: ast.Module) -> bool: + """A function returning ``ProviderHandler``, or a ``create_handler``/``ProviderHandler`` call.""" + for node in ast.walk(tree): + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.returns is not None + and ast.unparse(node.returns).split(".")[-1] == "ProviderHandler" + ): + return True + if isinstance(node, ast.Call): + func = node.func + name = ( + func.attr + if isinstance(func, ast.Attribute) + else getattr(func, "id", None) + ) + if name in {"create_handler", "ProviderHandler"}: + return True + return False + + +def _defines_provider_handler(trees: dict[Path, ast.Module]) -> bool: + return any( + isinstance(node, ast.ClassDef) and node.name == "ProviderHandler" + for tree in trees.values() + for node in ast.walk(tree) + ) + + +def discover_handler_modules(packages_dir: Path = _PACKAGES_DIR) -> set[str]: + """Modules under ``packages/*/src/launchdarkly_ai_*`` that build a ``ProviderHandler``. + + Read from source rather than imported, so a package whose provider SDK isn't installed is + still found. The package that defines ``ProviderHandler`` itself is the client core, not a + handler, and is skipped along with packages that build no handler (the meta-package). + """ + found: set[str] = set() + for package in sorted(packages_dir.glob("*/src/launchdarkly_ai_*")): + trees = { + path: ast.parse(path.read_text()) for path in sorted(package.rglob("*.py")) + } + if _defines_provider_handler(trees): + continue + found |= { + _module_name(path, package.parent) + for path, tree in trees.items() + if _provides_handler(tree) + } + return found + + +def test_every_handler_module_has_forwarded_lists() -> None: + discovered = discover_handler_modules() + assert discovered, f"found no handler modules under {_PACKAGES_DIR}" + assert discovered <= FORWARDED_LISTS.keys(), ( + f"handler modules with no entry in FORWARDED_LISTS: {sorted(discovered - FORWARDED_LISTS.keys())}" + ) + assert FORWARDED_LISTS.keys() <= discovered, ( + f"FORWARDED_LISTS names modules that build no handler: {sorted(FORWARDED_LISTS.keys() - discovered)}" + ) + + _CASES = [ (module, name) for module, names in FORWARDED_LISTS.items() From 9055c2ad924d04bd0f9e07be50e2155c3b8fdbce Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:06:33 -0400 Subject: [PATCH 15/26] feat(client): drop malformed object values when selecting forwarded parameters select_forwarded_parameters takes an optional mapping_keys set: forwarded keys whose value the provider expects to be an object. A value of any other type for one of them is dropped like an unlisted key, instead of reaching the provider call as written. --- .../parameter_forwarding.py | 27 ++++++++++++++----- .../client/tests/test_parameter_forwarding.py | 9 +++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py b/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py index 87463e1f..69622078 100644 --- a/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py +++ b/packages/client/src/launchdarkly_ai_server/parameter_forwarding.py @@ -5,16 +5,18 @@ sense across providers (``temperature``, ``top_p``, ``max_tokens``, ``tool_choice``, ...). No single provider SDK accepts all of them, and each handler package classifies every key its own provider entry point accepts into exactly one written-down list: forwarded (model and run settings -only), handler-owned (set by the call site itself), or excluded (accepted by the provider but never -forwarded: credentials, endpoints and connection settings, raw request injection such as extra -headers or ``model_kwargs``, remote tools, host-process settings, and anything that would break -the handler). Only the forwarded list is read at runtime; every other key is dropped. Those lists are +only, the cross-SDK list for that handler in TESTING.md section 1.12), handler-owned (set by the +call site itself), or excluded (accepted by the provider but never forwarded: credentials, endpoints +and connection settings, raw request injection such as extra headers or ``model_kwargs``, remote +tools, host-process settings, data location and retention, server-side state, identity and +attribution, runtime wiring and API-shape switches, prompt content beyond the instructions, and +safety configuration). Only the forwarded list is read at runtime; every other key is dropped. Those lists are literal, next to each handler's own call sites, not derived from the provider SDK at runtime: a hand-maintained list is reviewable and a runtime-derived one is not, and each handler package has a drift test asserting its lists still cover everything its provider SDK accepts. This module is the one shared piece: given a params dict and the literal forwarded-keys list, keep -only the keys on that list. +only the keys on that list, and drop any whose value should be an object but is not. """ from __future__ import annotations @@ -24,7 +26,10 @@ def select_forwarded_parameters( - params: Mapping[str, Any], forwarded_keys: frozenset[str] + params: Mapping[str, Any], + forwarded_keys: frozenset[str], + *, + mapping_keys: frozenset[str] = frozenset(), ) -> dict[str, Any]: """Returns the subset of *params* whose key is in *forwarded_keys*. @@ -34,5 +39,13 @@ def select_forwarded_parameters( in *params* but not in *forwarded_keys* is silently dropped rather than raising, matching this SDK's behaviour before ``model.parameters`` forwarding existed: an unrecognised tuning value is ignored, not a hard failure. + + *mapping_keys* names the forwarded keys whose value the provider expects to be an object + (``thinking``, ``reasoning``, ...). A value for one of them that is not a mapping is malformed + and dropped the same way, rather than passed through for the provider to reject. """ - return {k: v for k, v in params.items() if k in forwarded_keys} + return { + k: v + for k, v in params.items() + if k in forwarded_keys and (k not in mapping_keys or isinstance(v, Mapping)) + } diff --git a/packages/client/tests/test_parameter_forwarding.py b/packages/client/tests/test_parameter_forwarding.py index a40da7ed..938cd335 100644 --- a/packages/client/tests/test_parameter_forwarding.py +++ b/packages/client/tests/test_parameter_forwarding.py @@ -27,3 +27,12 @@ def test_does_not_mutate_input(self) -> None: params = {"temperature": 0.5, "unknown": 1} select_forwarded_parameters(params, frozenset({"temperature"})) assert params == {"temperature": 0.5, "unknown": 1} + + def test_drops_a_mapping_key_whose_value_is_not_a_mapping(self) -> None: + params = {"thinking": "enabled", "reasoning": {"effort": "low"}, "top_p": 0.5} + result = select_forwarded_parameters( + params, + frozenset({"thinking", "reasoning", "top_p"}), + mapping_keys=frozenset({"thinking", "reasoning"}), + ) + assert result == {"reasoning": {"effort": "low"}, "top_p": 0.5} From a2a7d733fa34dc19637d59917f82646951f139e2 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:06:33 -0400 Subject: [PATCH 16/26] test: add the cross-SDK forwarded-key lists and a per-key probe tests/forwarding_spec.py holds each handler's forwarded list from the cross-SDK spec (TESTING.md 1.12), in the config's own spelling, plus probe_forwarded_keys: it sets one key at a time and reports which ones change what the handler hands its provider. Package tests use it to assert each call site forwards exactly its list. --- tests/forwarding_spec.py | 240 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 tests/forwarding_spec.py diff --git a/tests/forwarding_spec.py b/tests/forwarding_spec.py new file mode 100644 index 00000000..76e77076 --- /dev/null +++ b/tests/forwarding_spec.py @@ -0,0 +1,240 @@ +"""The cross-SDK forwarded-key lists (TESTING.md section 1.12), shared by every package's tests. + +Each list names the ``model.parameters`` keys a handler forwards, in the config's own spelling, so +a key the handler renames (``effort``, ``max_tokens``, ``text``) is listed under the name the config +uses. The JS SDK is held to the same lists. Each package's tests probe its real call sites with +:func:`probe_forwarded_keys` and assert the result equals its list here exactly. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Iterable, Mapping +from typing import Any + +from tests.never_forwarded import NEVER_FORWARDED_KEYS, leaked + +CLAUDE_MESSAGES = frozenset( + { + "cache_control", + "effort", + "max_tokens", + "output_config", + "service_tier", + "stop_sequences", + "temperature", + "thinking", + "tool_choice", + "top_k", + "top_p", + } +) + +OPENAI_MESSAGES = frozenset( + { + "max_output_tokens", + "max_completion_tokens", + "max_tokens", + "max_tool_calls", + "parallel_tool_calls", + "prompt_cache_key", + "reasoning", + "service_tier", + "temperature", + "tool_choice", + "top_logprobs", + "top_p", + } +) + +CLAUDE_AGENTS = frozenset( + { + "betas", + "effort", + "fallback_model", + "max_budget_usd", + "max_thinking_tokens", + "max_turns", + "output_format", + "thinking", + } +) + +OPENAI_AGENTS = frozenset( + { + "frequency_penalty", + "max_tokens", + "max_turns", + "parallel_tool_calls", + "presence_penalty", + "reasoning", + "temperature", + "text", + "tool_choice", + "top_p", + "verbosity", + } +) + +LANGCHAIN_CHAT_OPENAI = frozenset( + { + "frequency_penalty", + "logit_bias", + "logprobs", + "max_completion_tokens", + "max_tokens", + "n", + "presence_penalty", + "prompt_cache_key", + "service_tier", + "stop", + "stop_sequences", + "temperature", + "top_logprobs", + "top_p", + "verbosity", + } +) + +#: Canonical keys ``ChatOpenAI`` (langchain-openai 1.3.3) has no field for, so the handler cannot +#: set them and leaves them out. +LANGCHAIN_CHAT_OPENAI_UNSUPPORTED = frozenset({"prompt_cache_key"}) + +LANGCHAIN_CHAT_ANTHROPIC = frozenset( + { + "betas", + "effort", + "max_tokens", + "max_tokens_to_sample", + "output_config", + "stop", + "stop_sequences", + "temperature", + "thinking", + "top_k", + "top_p", + } +) + +LANGCHAIN_CHAT_BEDROCK_CONVERSE = frozenset( + {"max_tokens", "performance_config", "service_tier", "temperature", "top_p"} +) + +#: Keys the UI or another provider uses that are on none of the lists above for some handler, so +#: every probe also checks they are dropped. +_OFF_LIST_KEYS = frozenset( + { + "context_management", + "frequency_penalty", + "guard_last_turn_only", + "guardrails", + "include", + "include_usage", + "inference_geo", + "instructions", + "logit_bias", + "logprobs", + "max_output_tokens", + "max_tokens", + "metadata", + "moderation", + "n", + "output_format", + "prompt_cache_retention", + "reasoning", + "reasoning_effort", + "request_metadata", + "response_format", + "response_include", + "safety_identifier", + "seed", + "stop", + "store", + "stream", + "system", + "text", + "top_logprobs", + "truncation", + "use_responses_api", + "user", + "user_profile_id", + "verbose", + "verbosity", + } +) + +#: A valid value for every canonical key, different from any default a handler applies, so +#: setting it alone always changes the provider call when the key is forwarded. +SAMPLE_VALUES: Mapping[str, Any] = { + "betas": ["context-1m-2025-08-07"], + "cache_control": {"type": "ephemeral"}, + "effort": "low", + "fallback_model": "claude-fallback", + "frequency_penalty": 0.3, + "logit_bias": {"50256": -100}, + "logprobs": True, + "max_budget_usd": 1.5, + "max_completion_tokens": 222, + "max_output_tokens": 333, + "max_thinking_tokens": 444, + "max_tokens": 111, + "max_tokens_to_sample": 555, + "max_tool_calls": 3, + "max_turns": 4, + "n": 2, + "output_config": {"effort": "high"}, + "output_format": {"type": "json_schema", "schema": {"type": "object"}}, + "parallel_tool_calls": False, + "performance_config": {"latency": "optimized"}, + "presence_penalty": 0.4, + "prompt_cache_key": "cache-1", + "reasoning": {"effort": "low"}, + "service_tier": "flex", + "stop": ["STOP"], + "stop_sequences": ["END"], + "temperature": 0.2, + "text": {"verbosity": "low"}, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "tool_choice": {"type": "auto"}, + "top_k": 7, + "top_logprobs": 2, + "top_p": 0.6, + "verbosity": "high", +} + + +def candidate_keys(*accepted: Iterable[str]) -> frozenset[str]: + """Every key worth probing: the provider's own accept-set(s), every canonical key, every + never-forwarded key, and the off-list keys.""" + keys: set[str] = set(_OFF_LIST_KEYS | NEVER_FORWARDED_KEYS) + for group in accepted: + keys.update(group) + return frozenset(keys) + + +def sample(key: str) -> Any: + """The probe value for *key*: a valid value for a canonical key, a :func:`leaked` marker for + any other.""" + return SAMPLE_VALUES[key] if key in SAMPLE_VALUES else leaked(key) + + +async def probe_forwarded_keys( + candidates: Iterable[str], + call: Callable[[dict[str, Any]], Awaitable[object]], +) -> frozenset[str]: + """The keys among *candidates* that change what *call* hands its provider when set alone. + + *call* runs the handler with the given ``model.parameters`` and returns whatever it handed the + provider. A key counts as forwarded when that result differs from the result with no + parameters, or when setting it makes the call raise. + """ + baseline = await call({}) + forwarded: set[str] = set() + for key in sorted(set(candidates)): + try: + result = await call({key: sample(key)}) + except Exception: + forwarded.add(key) + continue + if result != baseline: + forwarded.add(key) + return frozenset(forwarded) From 956315c1abbb185b7dc45777c2772c66cb4fe196 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:06:49 -0400 Subject: [PATCH 17/26] fix(claude-messages): forward exactly the cross-SDK Claude Messages list metadata leaves the forwarded list and joins the excluded side as identity and attribution, next to user_profile_id. Every excluded key now names its category. cache_control, output_config, thinking and tool_choice are dropped when they are not objects. invoke and stream read one helper, and a per-key probe of each path asserts both forward exactly the spec list. --- .../handler.py | 56 +++++---- .../claude-messages/tests/test_handler.py | 110 ++++++++++++++++++ 2 files changed, 146 insertions(+), 20 deletions(-) diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index 04fd9e49..c34a2777 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -50,13 +50,15 @@ #: ``TestMessagesStreamAcceptsExactlyTheseKeys`` in this package's tests assert this #: classification stays exhaustive as the SDK's own signatures change. #: -#: Only model and run settings are forwarded, and the same list serves ``invoke`` and ``stream``, -#: so one config behaves the same whichever is called. +#: This is the cross-SDK list for Claude Messages (TESTING.md section 1.12). A top-level +#: ``effort`` is also accepted and moved into ``output_config.effort`` before this list applies +#: (see :func:`_rename_effort_to_output_config`), and ``max_tokens`` defaults to 1024. The same list +#: serves ``invoke`` and ``stream`` (both read :func:`_forwarded_parameters`), so one config behaves +#: the same whichever is called. _MESSAGES_FORWARDED_KEYS = frozenset( { "cache_control", "max_tokens", - "metadata", "output_config", "service_tier", "stop_sequences", @@ -68,16 +70,23 @@ } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to the API. +_MESSAGES_MAPPING_KEYS = frozenset( + {"cache_control", "output_config", "thinking", "tool_choice"} +) + #: Accepted by the API but never forwarded, and why: -#: * ``stream``: the handler chooses blocking vs. streaming itself, not via a kwarg. -#: * ``output_format``: only ``.stream`` accepts it, so forwarding it would make ``invoke`` and -#: ``stream`` behave differently. ``output_config`` (forwarded) carries the same output format -#: on both. -#: * ``inference_geo``: the region inference runs in, which decides where data is processed. -#: * ``container``: selects server-side container state carried over from another request, not -#: a model setting. -#: * ``user_profile_id``: attributes the request to a party other than the caller, an identity -#: setting rather than a model setting. +#: * ``stream``: runtime wiring. The handler chooses blocking vs. streaming itself, not via a kwarg. +#: * ``output_format``: API-shape switch. Only ``.stream`` accepts it, so forwarding it would make +#: ``invoke`` and ``stream`` behave differently. ``output_config`` (forwarded) carries the same +#: output format on both. +#: * ``inference_geo``: data location. The region inference runs in decides where data is +#: processed. +#: * ``container``: server-side state. It selects container state carried over from another +#: request, not a model setting. +#: * ``metadata``, ``user_profile_id``: identity and attribution. They say who the request is for, +#: or attribute it to a party other than the caller. #: * ``timeout``, ``extra_headers``, ``extra_query``, ``extra_body``: client/connection #: configuration (a request timeout, raw HTTP overrides), never a config-controlled setting. #: @@ -89,6 +98,7 @@ "output_format", "inference_geo", "container", + "metadata", "user_profile_id", "timeout", "extra_headers", @@ -121,6 +131,18 @@ def _rename_effort_to_output_config(params: dict[str, Any]) -> dict[str, Any]: return params +def _forwarded_parameters(config: AiConfigRep) -> dict[str, Any]: + """The config's ``model.parameters`` that reach ``messages.create``/``.stream``: ``effort`` + moved into ``output_config``, then only :data:`_MESSAGES_FORWARDED_KEYS`, with malformed + object values dropped. ``invoke`` and ``stream`` both call this, so they forward the same keys. + """ + return select_forwarded_parameters( + _rename_effort_to_output_config(model_parameters(config)), + _MESSAGES_FORWARDED_KEYS, + mapping_keys=_MESSAGES_MAPPING_KEYS, + ) + + def _build_tools(config_tools: dict[str, Any]) -> list[dict[str, Any]]: return [ { @@ -272,10 +294,7 @@ async def _run_tool_loop( # difference predates this span work and changes what the model is offered, not what the span # reports, so it stays as it is: the catalog recorded below is the catalog actually sent. tools = _build_tools(config.get("tools") or {}) - extra_params = select_forwarded_parameters( - _rename_effort_to_output_config(model_parameters(config)), - _MESSAGES_FORWARDED_KEYS, - ) + extra_params = _forwarded_parameters(config) max_tokens = extra_params.pop("max_tokens", 1024) conversation = list(messages) output = "" @@ -568,10 +587,7 @@ async def _stream_gen( tools = _build_tools(config.get("tools") or {}) tool_definitions = to_tool_definitions(tools) - extra_params = select_forwarded_parameters( - _rename_effort_to_output_config(model_parameters(config)), - _MESSAGES_FORWARDED_KEYS, - ) + extra_params = _forwarded_parameters(config) max_tokens = extra_params.pop("max_tokens", 1024) conversation = list(messages) full_output = "" diff --git a/packages/claude-messages/tests/test_handler.py b/packages/claude-messages/tests/test_handler.py index d7331007..eef83e7b 100644 --- a/packages/claude-messages/tests/test_handler.py +++ b/packages/claude-messages/tests/test_handler.py @@ -16,6 +16,11 @@ import httpx import pytest +from tests.forwarding_spec import ( + CLAUDE_MESSAGES, + candidate_keys, + probe_forwarded_keys, +) from tests.never_forwarded import ( NEVER_FORWARDED_BAG, NEVER_FORWARDED_KEYS, @@ -2626,3 +2631,108 @@ async def test_stream(self, mock_anthropic: MagicMock) -> None: assert "output_format" not in call_kwargs assert call_kwargs["output_config"] == self._PARAMS["output_config"] assert call_kwargs["top_p"] == 0.4 + + +class TestForwardsExactlyTheCrossSdkList: + """Probes ``invoke`` and ``stream`` one key at a time: the keys that change the provider call + are exactly the cross-SDK Claude Messages list, on both paths.""" + + @staticmethod + def _candidates() -> frozenset[str]: + import inspect + + from anthropic.resources.messages import AsyncMessages + + accepted = { + name + for fn in (AsyncMessages.create, AsyncMessages.stream) + for name in inspect.signature(fn).parameters + if name != "self" + } + return candidate_keys(accepted, CLAUDE_MESSAGES) + + def _config(self, parameters: dict[str, Any]) -> dict[str, Any]: + return {**CONFIG, "model": {**CONFIG["model"], "parameters": parameters}} + + async def test_invoke(self, mock_anthropic: MagicMock) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + h = create_claude_messages_handler() + + async def call(parameters: dict[str, Any]) -> object: + await h(self._config(parameters), "q", {}, {}) + return mock_anthropic.messages.create.call_args.kwargs + + assert await probe_forwarded_keys(self._candidates(), call) == CLAUDE_MESSAGES + + async def test_stream(self, mock_anthropic: MagicMock) -> None: + import launchdarkly_ai_claude_messages.spans as spans_mod + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + mock_anthropic.messages.stream = MagicMock( + side_effect=lambda **_kw: _make_stream_context(["hi"])[0] + ) + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_claude_messages_handler() + + async def call(parameters: dict[str, Any]) -> object: + async for _event in await h.stream(self._config(parameters), "q", {}, {}): + pass + return mock_anthropic.messages.stream.call_args.kwargs + + assert await probe_forwarded_keys(self._candidates(), call) == CLAUDE_MESSAGES + + +class TestMalformedObjectValuesAreDropped: + """A key the API expects as an object, set to anything else, is dropped on both paths.""" + + _PARAMS: ClassVar[dict[str, Any]] = { + "thinking": "enabled", + "cache_control": "ephemeral", + "output_config": "json", + "tool_choice": "auto", + "top_p": 0.4, + } + + def _config(self) -> dict[str, Any]: + return {**CONFIG, "model": {**CONFIG["model"], "parameters": self._PARAMS}} + + def _assert_dropped(self, kwargs: dict[str, Any]) -> None: + assert not {"thinking", "cache_control", "output_config", "tool_choice"} & set( + kwargs + ) + assert kwargs["top_p"] == 0.4 + + async def test_invoke(self, mock_anthropic: MagicMock) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + await create_claude_messages_handler()(self._config(), "q", {}, {}) + self._assert_dropped(mock_anthropic.messages.create.call_args.kwargs) + + async def test_stream(self, mock_anthropic: MagicMock) -> None: + import launchdarkly_ai_claude_messages.spans as spans_mod + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + ctx, _ = _make_stream_context(["hi"]) + mock_anthropic.messages.stream = MagicMock(return_value=ctx) + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_claude_messages_handler() + async for _event in await h.stream(self._config(), "q", {}, {}): + pass + self._assert_dropped(mock_anthropic.messages.stream.call_args.kwargs) + + async def test_effort_still_lands_in_output_config_when_output_config_is_malformed( + self, mock_anthropic: MagicMock + ) -> None: + from launchdarkly_ai_claude_messages import create_claude_messages_handler + + config = { + **CONFIG, + "model": { + **CONFIG["model"], + "parameters": {"effort": "low", "output_config": "json"}, + }, + } + await create_claude_messages_handler()(config, "q", {}, {}) + kwargs = mock_anthropic.messages.create.call_args.kwargs + assert kwargs["output_config"] == {"effort": "low"} From fba0880aba7d7be93d94454ed37a4003dd6aa968 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:06:49 -0400 Subject: [PATCH 18/26] fix(openai-messages): forward exactly the cross-SDK OpenAI Messages list Drops context_management, include, instructions, metadata, moderation, prompt_cache_retention, safety_identifier, store, truncation and user from the Responses allowlist. Each is excluded with its reason: data retention, server-side state, identity and attribution, API-shape switch, prompt content beyond instructions, or safety configuration. A reasoning value that is not an object is dropped. A per-key probe of invoke and stream asserts both forward exactly the spec list. --- .../handler.py | 86 ++++++++----- .../openai-messages/tests/test_handler.py | 117 +++++++++++++++--- 2 files changed, 154 insertions(+), 49 deletions(-) diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index d84a52ab..5dea157c 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -62,44 +62,51 @@ #: The UI writes reasoning effort as ``reasoning: {"effort": ...}``, the Responses API's own shape, #: so ``reasoning`` is forwarded as-is and no top-level ``reasoning_effort`` is read. #: -#: Only model and run settings are forwarded, and the same list serves ``invoke`` and ``stream``, -#: so one config behaves the same whichever is called. +#: This is the cross-SDK list for OpenAI Messages (TESTING.md section 1.12). The same list serves +#: ``invoke`` and ``stream`` (both read :func:`_forwarded_parameters`), so one config behaves the +#: same whichever is called. _RESPONSES_FORWARDED_KEYS = frozenset( { - "context_management", - "include", - "instructions", "max_output_tokens", "max_tool_calls", - "metadata", - "moderation", "parallel_tool_calls", "prompt_cache_key", - "prompt_cache_retention", "reasoning", - "safety_identifier", "service_tier", - "store", "temperature", "tool_choice", "top_logprobs", "top_p", - "truncation", - "user", } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to the API. +_RESPONSES_MAPPING_KEYS = frozenset({"reasoning"}) + #: Accepted by the API but never forwarded, and why: -#: * ``stream``: the handler chooses blocking vs. streaming itself, not via a kwarg. -#: * ``stream_options``: only meaningful together with ``stream=True``, which the handler controls. -#: * ``background``: returns before the output exists, so the handler would get no result. -#: * ``conversation``: server-side conversation state conflicts with the input the handler builds. -#: * ``prompt``: server-side prompt template conflicts with the input the handler builds. -#: * ``response_id``, ``starting_after``: only ``.stream`` accepts them, to resume an existing -#: response rather than start a new one. That is request state, not a setting, and ``invoke`` has -#: no equivalent. -#: * ``text_format``: only ``.stream`` accepts it, and it is a Python type to parse into, which a -#: config cannot express. Structured output goes through ``text``, which the handler owns. +#: * ``stream``, ``stream_options``: runtime wiring. The handler chooses blocking vs. streaming +#: itself, and ``stream_options`` only means something together with ``stream=True``. +#: * ``background``: runtime wiring. It returns before the output exists, so the handler would get +#: no result. +#: * ``include``: API-shape switch. It adds output items the handler does not read, such as +#: encrypted reasoning or search results. +#: * ``conversation``, ``context_management``, ``truncation``: server-side state. They carry, trim +#: or compact conversation state the server keeps, which conflicts with the input the handler +#: builds. +#: * ``response_id``, ``starting_after``: server-side state. Only ``.stream`` accepts them, to +#: resume an existing response rather than start a new one, and ``invoke`` has no equivalent. +#: * ``store``, ``prompt_cache_retention``: data retention. They decide whether, and for how long, +#: the request and its cache stay on the server. +#: * ``metadata``, ``user``, ``safety_identifier``: identity and attribution. They say who the +#: request is for. +#: * ``prompt``, ``instructions``: prompt content beyond the config's instructions. The handler +#: builds the input from the config's own instructions and messages, and a server-side prompt +#: template or a second instructions string would replace or add to it. +#: * ``moderation``: safety configuration. +#: * ``text_format``: API-shape switch. Only ``.stream`` accepts it, and it is a Python type to +#: parse into, which a config cannot express. Structured output goes through ``text``, which the +#: handler owns. #: * ``timeout``, ``extra_headers``, ``extra_query``, ``extra_body``: client/connection #: configuration (a request timeout, raw HTTP overrides), never a config-controlled setting. #: @@ -110,10 +117,20 @@ "stream", "stream_options", "background", + "include", "conversation", - "prompt", + "context_management", + "truncation", "response_id", "starting_after", + "store", + "prompt_cache_retention", + "metadata", + "user", + "safety_identifier", + "prompt", + "instructions", + "moderation", "text_format", "timeout", "extra_headers", @@ -140,6 +157,19 @@ def _apply_max_output_tokens_rename(params: dict[str, Any]) -> dict[str, Any]: return params +def _forwarded_parameters(config: AiConfigRep) -> dict[str, Any]: + """The config's ``model.parameters`` that reach ``responses.create``/``.stream``: + ``max_tokens``/``max_completion_tokens`` renamed to ``max_output_tokens``, then only + :data:`_RESPONSES_FORWARDED_KEYS`, with malformed object values dropped. ``invoke`` and + ``stream`` both call this, so they forward the same keys. + """ + return select_forwarded_parameters( + _apply_max_output_tokens_rename(model_parameters(config)), + _RESPONSES_FORWARDED_KEYS, + mapping_keys=_RESPONSES_MAPPING_KEYS, + ) + + def _build_tools(config_tools: dict[str, Any]) -> list[dict[str, Any]]: # Not filtered to the tools that have a registered handler, unlike the TypeScript SDK. That # difference predates this span work and changes what the model is offered, not what the span @@ -354,10 +384,7 @@ async def _call_impl( messages=root_messages, ) - extra_params = select_forwarded_parameters( - _apply_max_output_tokens_rename(model_parameters(config)), - _RESPONSES_FORWARDED_KEYS, - ) + extra_params = _forwarded_parameters(config) params: dict[str, Any] = { **extra_params, "model": config["model"]["name"], @@ -588,10 +615,7 @@ async def _stream_gen( tool_definitions=tool_definitions, ) - extra_params = select_forwarded_parameters( - _apply_max_output_tokens_rename(model_parameters(config)), - _RESPONSES_FORWARDED_KEYS, - ) + extra_params = _forwarded_parameters(config) stream_params: dict[str, Any] = { **extra_params, "model": config["model"]["name"], diff --git a/packages/openai-messages/tests/test_handler.py b/packages/openai-messages/tests/test_handler.py index 813edb55..d434b649 100644 --- a/packages/openai-messages/tests/test_handler.py +++ b/packages/openai-messages/tests/test_handler.py @@ -15,6 +15,11 @@ import httpx import pytest +from tests.forwarding_spec import ( + OPENAI_MESSAGES, + candidate_keys, + probe_forwarded_keys, +) from tests.never_forwarded import ( NEVER_FORWARDED_BAG, NEVER_FORWARDED_KEYS, @@ -1303,43 +1308,39 @@ async def test_stream_key_is_never_forwarded(self, mock_openai: MagicMock) -> No ): assert excluded not in kwargs - async def test_store_and_other_non_generation_keys_are_forwarded( + async def test_retention_identity_and_prompt_keys_are_dropped( self, mock_openai: MagicMock ) -> None: - """Only keys that would break the handler are excluded; everything else the API accepts - is forwarded, even keys that are not strictly generation settings.""" + """Data retention, identity, prompt content and safety keys the API accepts are dropped; + ``prompt_cache_key`` and ``service_tier`` next to them still forward.""" from launchdarkly_ai_openai_messages import create_openai_messages_handler + dropped = { + "store": False, + "prompt_cache_retention": "24h", + "user": "user-1", + "safety_identifier": "safe-1", + "metadata": {"k": "v"}, + "instructions": "be terse", + "moderation": "auto", + } config = { **CONFIG, "model": { **CONFIG["model"], "parameters": { - "store": False, - "user": "user-1", - "safety_identifier": "safe-1", + **dropped, "prompt_cache_key": "cache-1", - "metadata": {"k": "v"}, "service_tier": "auto", - "instructions": "be terse", - "moderation": "auto", }, }, } h = create_openai_messages_handler() await h(config, "q", {}, {}) kwargs = mock_openai.responses.create.call_args.kwargs - assert kwargs["store"] is False - assert kwargs["user"] == "user-1" - assert kwargs["safety_identifier"] == "safe-1" + assert not set(dropped) & set(kwargs) assert kwargs["prompt_cache_key"] == "cache-1" - assert kwargs["metadata"] == {"k": "v"} assert kwargs["service_tier"] == "auto" - assert kwargs["moderation"] == "auto" - # `instructions` is a plain accepted Responses API parameter here, not one this handler - # sets itself (it builds the system prompt into `input`, not a top-level `instructions` - # field), so a config value forwards through like any other accepted key. - assert kwargs["instructions"] == "be terse" class TestMaxOutputTokensRename: @@ -2593,3 +2594,83 @@ async def test_stream(self, mock_openai: MagicMock) -> None: kwargs = mock_openai.responses.stream.call_args.kwargs assert not {"response_id", "starting_after", "text_format"} & set(kwargs) assert kwargs["top_p"] == 0.4 + + +class TestForwardsExactlyTheCrossSdkList: + """Probes ``invoke`` and ``stream`` one key at a time: the keys that change the provider call + are exactly the cross-SDK OpenAI Messages list, on both paths.""" + + @staticmethod + def _candidates() -> frozenset[str]: + import inspect + + from openai.resources.responses import AsyncResponses + + accepted = { + name + for fn in (AsyncResponses.create, AsyncResponses.stream) + for name in inspect.signature(fn).parameters + if name != "self" + } + return candidate_keys(accepted, OPENAI_MESSAGES) + + def _config(self, parameters: dict[str, Any]) -> dict[str, Any]: + return {**CONFIG, "model": {**CONFIG["model"], "parameters": parameters}} + + async def test_invoke(self, mock_openai: MagicMock) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + h = create_openai_messages_handler() + + async def call(parameters: dict[str, Any]) -> object: + await h(self._config(parameters), "q", {}, {}) + return mock_openai.responses.create.call_args.kwargs + + assert await probe_forwarded_keys(self._candidates(), call) == OPENAI_MESSAGES + + async def test_stream(self, mock_openai: MagicMock) -> None: + import launchdarkly_ai_openai_messages.spans as spans_mod + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + mock_openai.responses.stream = MagicMock( + side_effect=lambda **_kw: _make_openai_stream_context(["hi"]) + ) + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_openai_messages_handler() + + async def call(parameters: dict[str, Any]) -> object: + [e async for e in await h.stream(self._config(parameters), "q")] + return mock_openai.responses.stream.call_args.kwargs + + assert await probe_forwarded_keys(self._candidates(), call) == OPENAI_MESSAGES + + +class TestMalformedObjectValuesAreDropped: + """``reasoning`` set to anything but an object is dropped on both paths.""" + + _PARAMS: ClassVar[dict[str, Any]] = {"reasoning": "high", "top_p": 0.4} + + def _config(self) -> dict[str, Any]: + return {**CONFIG, "model": {**CONFIG["model"], "parameters": self._PARAMS}} + + async def test_invoke(self, mock_openai: MagicMock) -> None: + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + await create_openai_messages_handler()(self._config(), "q", {}, {}) + kwargs = mock_openai.responses.create.call_args.kwargs + assert "reasoning" not in kwargs + assert kwargs["top_p"] == 0.4 + + async def test_stream(self, mock_openai: MagicMock) -> None: + import launchdarkly_ai_openai_messages.spans as spans_mod + from launchdarkly_ai_openai_messages import create_openai_messages_handler + + mock_openai.responses.stream = MagicMock( + return_value=_make_openai_stream_context(["hi"]) + ) + with patch.object(spans_mod, "_HAS_OTEL", False): + h = create_openai_messages_handler() + [e async for e in await h.stream(self._config(), "q")] + kwargs = mock_openai.responses.stream.call_args.kwargs + assert "reasoning" not in kwargs + assert kwargs["top_p"] == 0.4 From 4b03acd2ae41b04ce8f71d92be082f4f7e1c0136 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:06:50 -0400 Subject: [PATCH 19/26] fix(openai-agents): forward exactly the cross-SDK OpenAI Agents list ModelSettings no longer takes context_management, include_usage, metadata, prompt_cache_retention, response_include, store, top_logprobs or truncation from a config; each is excluded with its reason. text is read only for text.verbosity, which becomes verbosity unless the config set verbosity itself, and a text or reasoning value that is not an object is dropped. The handler and the native graph share one helper, and per-key probes of invoke, stream and the native graph assert they forward exactly the spec list, with max_turns going to the run. --- .../launchdarkly_ai_openai_agents/handler.py | 75 ++++++++++--- .../native_graph.py | 7 +- packages/openai-agents/tests/test_handler.py | 100 ++++++++++++++++++ .../openai-agents/tests/test_native_graph.py | 51 +++++++++ 4 files changed, 212 insertions(+), 21 deletions(-) diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index a2ff32d1..6109ae85 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -21,7 +21,7 @@ import asyncio import inspect import json -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from typing import Any from launchdarkly_ai_server import ( @@ -80,38 +80,83 @@ #: ``TestModelSettingsAcceptsExactlyTheseFields`` in this package's tests asserts this #: classification stays exhaustive as the SDK's own dataclass changes. #: -#: Excluded, and why: all client/connection configuration, never a config-controlled setting. -#: * ``retry``: an HTTP retry count. -#: * ``extra_headers``, ``extra_query``, ``extra_body``, ``extra_args``: raw HTTP/request overrides. +#: This is the cross-SDK list for OpenAI Agents (TESTING.md section 1.12), less the two keys that +#: are not ``ModelSettings`` fields: ``max_turns`` goes to the run, and ``text`` is read only for +#: ``text.verbosity``, which becomes ``verbosity`` (see :func:`_apply_text_verbosity`). _MODEL_SETTINGS_FORWARDED_KEYS = frozenset( { - "context_management", "frequency_penalty", - "include_usage", "max_tokens", - "metadata", "parallel_tool_calls", "presence_penalty", - "prompt_cache_retention", "reasoning", - "response_include", - "store", "temperature", "tool_choice", - "top_logprobs", "top_p", - "truncation", "verbosity", } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to ``ModelSettings``. +_MODEL_SETTINGS_MAPPING_KEYS = frozenset({"reasoning"}) + +#: Declared by ``ModelSettings`` but never forwarded, and why: +#: * ``context_management``, ``truncation``: server-side state. They trim or compact conversation +#: state the server keeps. +#: * ``store``, ``prompt_cache_retention``: data retention. They decide whether, and for how long, +#: the request and its cache stay on the server. +#: * ``metadata``: identity and attribution. +#: * ``include_usage``: runtime wiring. It changes how usage is reported back to the handler. +#: * ``response_include``, ``top_logprobs``: API-shape switch. They add output the handler does not +#: read. +#: * ``retry``: client/connection configuration, an HTTP retry count. +#: * ``extra_headers``, ``extra_query``, ``extra_body``, ``extra_args``: raw HTTP/request overrides. +#: #: Named for the drift test and for review, not read at runtime: the forwarded list above already #: leaves these out, so nothing needs to subtract them again. _MODEL_SETTINGS_EXCLUDED_KEYS = frozenset( - {"retry", "extra_headers", "extra_query", "extra_body", "extra_args"} + { + "context_management", + "truncation", + "store", + "prompt_cache_retention", + "metadata", + "include_usage", + "response_include", + "top_logprobs", + "retry", + "extra_headers", + "extra_query", + "extra_body", + "extra_args", + } ) +def _apply_text_verbosity(params: dict[str, Any]) -> dict[str, Any]: + """Moves ``text.verbosity`` to ``ModelSettings``'s top-level ``verbosity`` and drops the rest + of ``text``. A top-level ``verbosity`` the config already set wins, and a ``text`` that is not + an object is malformed and dropped. + """ + text = params.pop("text", None) + if isinstance(text, Mapping) and "verbosity" in text and "verbosity" not in params: + params["verbosity"] = text["verbosity"] + return params + + +def _model_settings_parameters(config: AiConfigRep) -> dict[str, Any]: + """The config's ``model.parameters`` that become ``ModelSettings`` fields. The handler and the + native graph both call this, so they forward the same keys. ``max_turns`` is not among them: + it is a ``Runner.run`` option, read separately for the run. + """ + return select_forwarded_parameters( + _apply_text_verbosity(model_parameters(config)), + _MODEL_SETTINGS_FORWARDED_KEYS, + mapping_keys=_MODEL_SETTINGS_MAPPING_KEYS, + ) + + def _build_agent_tools( config_tools: dict[str, Any], tool_handlers: dict[str, Any], @@ -255,9 +300,7 @@ def _build_agent_and_prompt( # return-shape contract. # `max_turns` is a `Runner.run` option, not a `ModelSettings` field, so the filter drops it here # and the run call reads it separately. - model_settings_params = select_forwarded_parameters( - model_parameters(config), _MODEL_SETTINGS_FORWARDED_KEYS - ) + model_settings_params = _model_settings_parameters(config) agent = Agent( name="assistant", model=config.get("model", {}).get("name", "gpt-4o"), diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index acececd4..2f81152b 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -22,11 +22,10 @@ parse_template, to_ld_context, ) -from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters from launchdarkly_ai_server.utils import model_parameters from .handler import ( - _MODEL_SETTINGS_FORWARDED_KEYS, + _model_settings_parameters, _parse_message_content, _to_openai_agent_items, ) @@ -182,9 +181,7 @@ async def _visit(node_key: str) -> None: agent_name = _sanitize_name(node.key) agent_name_to_key[agent_name] = node.key - node_model_settings_params = select_forwarded_parameters( - model_parameters(node.config), _MODEL_SETTINGS_FORWARDED_KEYS - ) + node_model_settings_params = _model_settings_parameters(node.config) agent = Agent( name=agent_name, model=node.config.get("model", {}).get("name", "gpt-4o"), diff --git a/packages/openai-agents/tests/test_handler.py b/packages/openai-agents/tests/test_handler.py index ab950f81..d2fe18e4 100644 --- a/packages/openai-agents/tests/test_handler.py +++ b/packages/openai-agents/tests/test_handler.py @@ -27,6 +27,11 @@ openai_agents, ) from launchdarkly_ai_openai_agents.utils import build_output_type +from tests.forwarding_spec import ( + OPENAI_AGENTS, + candidate_keys, + probe_forwarded_keys, +) from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks # --------------------------------------------------------------------------- @@ -2309,3 +2314,98 @@ def run_streamed(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: model_settings = captured["agent"].kwargs["model_settings"] assert model_settings.kwargs == {"temperature": 0.1} assert not find_leaks(captured["run_kwargs"]) + + +def _settings_and_run(captured: dict[str, Any]) -> object: + """What one handler call handed the SDK from the config: the ``ModelSettings`` kwargs (or + none) and the ``Runner`` kwargs.""" + settings = captured["agent"].kwargs.get("model_settings") + return (settings.kwargs if settings is not None else None, captured["run_kwargs"]) + + +class TestForwardsExactlyTheCrossSdkList: + """Probes ``invoke`` and ``stream`` one key at a time: the keys that change ``ModelSettings`` + or the ``Runner`` call are exactly the cross-SDK OpenAI Agents list, on both paths.""" + + @staticmethod + def _candidates() -> frozenset[str]: + import dataclasses + + from agents import ModelSettings + + return candidate_keys( + (f.name for f in dataclasses.fields(ModelSettings)), OPENAI_AGENTS + ) + + @staticmethod + def _config(parameters: dict[str, Any]) -> dict[str, Any]: + return _make_config( + instructions="Be helpful.", + model={"name": "gpt-4o", "parameters": parameters}, + ) + + async def test_invoke(self) -> None: + captured: dict[str, Any] = {} + + async def run(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + captured["agent"] = agent + captured["run_kwargs"] = kw + await _drive_turns(hooks, agent, prompt, [{"output": _text_output("hi")}]) + return FakeRunResult("done") + + async def call(parameters: dict[str, Any]) -> object: + with _patched_agents(_fake_agents_module(run=run)): + await create_openai_agent_handler()( + self._config(parameters), "q", {}, {} + ) + return _settings_and_run(captured) + + assert await probe_forwarded_keys(self._candidates(), call) == OPENAI_AGENTS + + async def test_stream(self) -> None: + captured: dict[str, Any] = {} + + def run_streamed(agent: Any, prompt: str, hooks: Any = None, **kw: Any) -> Any: + captured["agent"] = agent + captured["run_kwargs"] = kw + return FakeStreamedResult( + agent, prompt, hooks, [{"output": _text_output("hi")}], "done" + ) + + async def call(parameters: dict[str, Any]) -> object: + with _patched_agents(_fake_agents_module(run_streamed=run_streamed)): + gen = await create_openai_agent_handler().stream( + self._config(parameters), "q", {}, {} + ) + async for _event in gen: + pass + return _settings_and_run(captured) + + assert await probe_forwarded_keys(self._candidates(), call) == OPENAI_AGENTS + + +class TestTextVerbosity: + def _settings(self, parameters: dict[str, Any]) -> dict[str, Any]: + from launchdarkly_ai_openai_agents.handler import _model_settings_parameters + + return _model_settings_parameters({"model": {"parameters": parameters}}) + + def test_text_verbosity_becomes_verbosity(self) -> None: + assert self._settings({"text": {"verbosity": "low", "format": {}}}) == { + "verbosity": "low" + } + + def test_top_level_verbosity_wins(self) -> None: + assert self._settings({"verbosity": "high", "text": {"verbosity": "low"}}) == { + "verbosity": "high" + } + + def test_text_that_is_not_an_object_is_dropped(self) -> None: + assert self._settings({"text": "low", "temperature": 0.1}) == { + "temperature": 0.1 + } + + def test_reasoning_that_is_not_an_object_is_dropped(self) -> None: + assert self._settings({"reasoning": "high", "temperature": 0.1}) == { + "temperature": 0.1 + } diff --git a/packages/openai-agents/tests/test_native_graph.py b/packages/openai-agents/tests/test_native_graph.py index 80b0e567..f1f6ba5f 100644 --- a/packages/openai-agents/tests/test_native_graph.py +++ b/packages/openai-agents/tests/test_native_graph.py @@ -16,6 +16,11 @@ to_openai_agents, ) from launchdarkly_ai_server import GraphDefinition, GraphEdge, GraphNode +from tests.forwarding_spec import ( + OPENAI_AGENTS, + candidate_keys, + probe_forwarded_keys, +) from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks # --------------------------------------------------------------------------- @@ -906,3 +911,49 @@ def sync_wrapper(_args: Any) -> Any: _build_node_tools(node, {"my-tool": sync_wrapper}) assert await captured[0]({}) == "done" + + +class TestNativeGraphForwardsExactlyTheCrossSdkList: + """The native graph builds each agent's ``ModelSettings`` with the handler's own forwarding + and reads the root's ``max_turns`` for the run, so a probe of one node finds exactly the + cross-SDK OpenAI Agents list.""" + + @pytest.mark.asyncio + async def test_root_agent_and_run(self) -> None: + import dataclasses + + from agents import ModelSettings + + async def call(parameters: dict[str, Any]) -> object: + agents_mock = _make_agents_mock(_make_run_result("out")) + agents_mock.ModelSettings = MagicMock(side_effect=lambda **kw: dict(kw)) + nodes = { + "root": { + "key": "root", + "config": { + "model": {"name": "gpt-4o", "parameters": parameters}, + "instructions": "help", + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + with patch( + "importlib.import_module", + side_effect=lambda n: agents_mock if n == "agents" else __import__(n), + ): + await to_openai_agents( + _make_def_promise(_make_graph_def(nodes=nodes)) + ).invoke("hi") + (root_agent,) = agents_mock._created_agents + run_kwargs = agents_mock.Runner.run.call_args.kwargs + return ( + root_agent._kw.get("model_settings"), + {k: v for k, v in run_kwargs.items() if k not in {"hooks", "context"}}, + ) + + candidates = candidate_keys( + (f.name for f in dataclasses.fields(ModelSettings)), OPENAI_AGENTS + ) + assert await probe_forwarded_keys(candidates, call) == OPENAI_AGENTS From f9b1bf56246583773708a3a380e53a897ce852ec Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:06:50 -0400 Subject: [PATCH 20/26] fix(claude-agents): drop thinking and output_format values that are not objects The forwarded list is unchanged and already matches the cross-SDK list. The handler and the native graph now share one helper that also drops malformed object values, and per-key probes of invoke, stream and the native graph assert they forward exactly the spec list. --- .../launchdarkly_ai_claude_agents/handler.py | 23 ++++- .../native_graph.py | 8 +- packages/claude-agents/tests/test_handler.py | 92 +++++++++++++++++++ .../claude-agents/tests/test_native_graph.py | 53 +++++++++++ 4 files changed, 167 insertions(+), 9 deletions(-) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 33b0dcd9..6c2c2b00 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -79,6 +79,9 @@ #: environment, working directory, file access, permissions, settings files, plugins, sandbox, #: session state) stays under the application's control, never a config's. #: +#: This is the cross-SDK list for Claude Agents (TESTING.md section 1.12), shared with the native +#: graph through :func:`_options_parameters`. +#: #: The SDK offers no ``temperature``/``top_p``/``top_k``/``max_tokens``/``stop_sequences``/ #: ``tool_choice``/``metadata``, all of which the LaunchDarkly UI's model parameters panel offers #: for other providers; they are dropped like any other key not listed here. @@ -95,6 +98,10 @@ } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to ``ClaudeAgentOptions``. +_CLAUDE_AGENT_OPTIONS_MAPPING_KEYS = frozenset({"output_format", "thinking"}) + #: Accepted by ``ClaudeAgentOptions`` but never forwarded, and why: #: * ``cli_path``, ``env``, ``cwd``, ``add_dirs``, ``settings``, ``setting_sources``, ``plugins``, #: ``skills``, ``sandbox``, ``user``, ``extra_args``: which binary runs, with what environment, @@ -552,6 +559,18 @@ def _result_error(subtype: str, errors: list[str] | None) -> str: return f"Claude agent run ended with {subtype}{detail}" +def _options_parameters(config: AiConfigRep) -> dict[str, Any]: + """The config's ``model.parameters`` that become ``ClaudeAgentOptions`` fields: only + :data:`_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS`, with malformed object values dropped. The handler + and the native graph both call this, so they forward the same keys. + """ + return select_forwarded_parameters( + model_parameters(config), + _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS, + mapping_keys=_CLAUDE_AGENT_OPTIONS_MAPPING_KEYS, + ) + + def _build_query_options( config: AiConfigRep, system_prompt: str | None, @@ -562,9 +581,7 @@ def _build_query_options( **extra: Any, ) -> ClaudeAgentOptions: all_allowed = [*mcp_allowed_tools, *native_tool_names] - params = select_forwarded_parameters( - model_parameters(config), _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS - ) + params = _options_parameters(config) kwargs: dict[str, Any] = { **params, "model": config["model"]["name"], diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index 7a14733a..f51e15db 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -20,8 +20,6 @@ make_track_data, to_ld_context, ) -from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters -from launchdarkly_ai_server.utils import model_parameters try: from opentelemetry import trace @@ -32,8 +30,8 @@ _HAS_OTEL = False from launchdarkly_ai_claude_agents.handler import ( - _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS, _build_hooks, + _options_parameters, build_prompt, build_query_prompt, build_tool_mcp, @@ -152,9 +150,7 @@ async def _run_query( hooks = _build_hooks(native_tool_map) - params = select_forwarded_parameters( - model_parameters(node.config), _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS - ) + params = _options_parameters(node.config) options = ClaudeAgentOptions( **params, diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index 970565fc..8ca3afb2 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -21,6 +21,7 @@ import pytest from claude_agent_sdk import ( AssistantMessage, + ClaudeAgentOptions, ResultMessage, StreamEvent, SystemMessage, @@ -43,6 +44,11 @@ partition_tools, ) from launchdarkly_ai_server import ConversationIdSpanProcessor, conversation_id +from tests.forwarding_spec import ( + CLAUDE_AGENTS, + candidate_keys, + probe_forwarded_keys, +) from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks # --------------------------------------------------------------------------- @@ -2349,3 +2355,89 @@ async def _query(**kwargs: Any) -> AsyncIterator[Any]: tool = named("execute_tool ")[0] assert tool.end_time is not None + + +#: ``ClaudeAgentOptions`` fields the handler fills with fresh objects on every call (hook +#: closures, the tool MCP server), so they differ between calls whatever the config says. Every +#: config-settable field is still compared. +_PER_CALL_FIELDS = frozenset({"hooks", "mcp_servers"}) + + +def _comparable(options: Any) -> dict[str, Any]: + import dataclasses + + return { + f.name: getattr(options, f.name) + for f in dataclasses.fields(options) + if f.name not in _PER_CALL_FIELDS + } + + +class TestForwardsExactlyTheCrossSdkList: + """Probes ``invoke`` and ``stream`` one key at a time: the keys that change the + ``ClaudeAgentOptions`` handed to ``query`` are exactly the cross-SDK Claude Agents list, on + both paths.""" + + @staticmethod + def _candidates() -> frozenset[str]: + import dataclasses + + return candidate_keys( + (f.name for f in dataclasses.fields(ClaudeAgentOptions)), CLAUDE_AGENTS + ) + + @staticmethod + def _config(parameters: dict[str, Any]) -> dict[str, Any]: + return { + **BASE_CONFIG, + "model": {**BASE_CONFIG["model"], "parameters": parameters}, + } + + def _patch_query(self, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + captured: dict[str, Any] = {} + + async def _query(**kwargs: Any) -> AsyncIterator[Any]: + captured["options"] = kwargs["options"] + yield assistant_message() + yield result_message() + + monkeypatch.setattr(handler_mod, "query", _query) + return captured + + async def test_invoke(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured = self._patch_query(monkeypatch) + h = create_claude_agents_handler() + + async def call(parameters: dict[str, Any]) -> object: + await h(self._config(parameters), "q") + return _comparable(captured["options"]) + + assert await probe_forwarded_keys(self._candidates(), call) == CLAUDE_AGENTS + + async def test_stream(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured = self._patch_query(monkeypatch) + h = create_claude_agents_handler() + + async def call(parameters: dict[str, Any]) -> object: + await _collect(await h.stream(self._config(parameters), "q")) + return _comparable(captured["options"]) + + assert await probe_forwarded_keys(self._candidates(), call) == CLAUDE_AGENTS + + +class TestMalformedObjectValuesAreDropped: + def test_thinking_and_output_format_that_are_not_objects_are_dropped(self) -> None: + from launchdarkly_ai_claude_agents.handler import _options_parameters + + params = _options_parameters( + { + "model": { + "parameters": { + "thinking": "adaptive", + "output_format": "json", + "max_turns": 3, + } + } + } + ) + assert params == {"max_turns": 3} diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index aa243a18..80d3a28f 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -14,6 +14,11 @@ import launchdarkly_ai_claude_agents.native_graph as _claude_ng from launchdarkly_ai_claude_agents.native_graph import to_claude_agents from launchdarkly_ai_server import GraphDefinition, GraphEdge, GraphNode, NativeTool +from tests.forwarding_spec import ( + CLAUDE_AGENTS, + candidate_keys, + probe_forwarded_keys, +) from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks # --------------------------------------------------------------------------- @@ -822,3 +827,51 @@ async def test_node_options_take_run_settings_and_nothing_never_forwarded( for opts in captured_options: assert opts["max_turns"] == 7 assert not find_leaks(opts) + + +class TestNativeGraphForwardsExactlyTheCrossSdkList: + """The native graph builds each node's options with the handler's own forwarding, so a probe + of one node finds exactly the cross-SDK Claude Agents list.""" + + @pytest.mark.asyncio + async def test_root_node_options(self) -> None: + import dataclasses + + from claude_agent_sdk import ClaudeAgentOptions + + async def call(parameters: dict[str, Any]) -> object: + mock_sdk = _make_sdk_mock("done") + nodes = { + "root": { + "key": "root", + "config": { + "model": {"name": "claude-3", "parameters": parameters}, + "instructions": "be helpful", + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + captured: list[dict[str, Any]] = [] + mock_sdk.ClaudeAgentOptions = MagicMock( + side_effect=lambda **kw: (captured.append(kw), kw)[1] + ) + with patch( + "importlib.import_module", + side_effect=lambda n: ( + mock_sdk if n == "claude_agent_sdk" else __import__(n) + ), + ): + await to_claude_agents( + _make_def_promise(_make_graph_def(nodes=nodes)) + ).invoke("hi") + return [ + {k: v for k, v in kw.items() if k not in {"hooks", "mcp_servers"}} + for kw in captured + ] + + candidates = candidate_keys( + (f.name for f in dataclasses.fields(ClaudeAgentOptions)), CLAUDE_AGENTS + ) + assert await probe_forwarded_keys(candidates, call) == CLAUDE_AGENTS From 1c60bdfd0a815f6fdd21851d91a7ec516370bce9 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:06:50 -0400 Subject: [PATCH 21/26] fix(langchain): forward exactly the cross-SDK lists for each chat model ChatOpenAI drops context_management, include, reasoning, reasoning_effort, seed, store and truncation. ChatAnthropic drops context_management. ChatBedrockConverse drops guardrails, guardrail_config, guard_last_turn_only, request_metadata, system, output_config, reasoning_effort, stop and stop_sequences. Each removed key is excluded with its reason. The use_responses_api reason now holds: include, reasoning, truncation and context_management also switch ChatOpenAI to the Responses API, and all of them are excluded together. prompt_cache_key is on the spec list but ChatOpenAI has no such field, so it is left out, with a test that fails once the field exists. Object-valued keys (logit_bias, thinking, output_config, performance_config) are dropped when malformed. Both packages probe each constructor one key at a time against the spec list, check that invoke and stream hand the builder the same config, and probe the LangGraph native graph's ChatOpenAI too. --- .../handler.py | 105 +++++++++--- .../native_graph.py | 6 +- .../tests/test_native_graph.py | 43 +++++ .../tests/test_parameter_forwarding.py | 154 +++++++++++++++++ .../handler.py | 106 +++++++++--- .../tests/test_parameter_forwarding.py | 155 ++++++++++++++++++ 6 files changed, 514 insertions(+), 55 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 d4b083b9..bbe18e5b 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -80,33 +80,31 @@ #: ``TestChatOpenAIAcceptsExactlyTheseKeys`` in this package's tests asserts this classification #: stays exhaustive as the SDK's own pydantic model changes. #: -#: Only model request settings are forwarded. +#: This is the cross-SDK list for LangChain ChatOpenAI (TESTING.md section 1.12), less +#: ``prompt_cache_key``: ``ChatOpenAI`` has no such field, so the handler cannot set it. _CHAT_OPENAI_FORWARDED_KEYS = frozenset( { - "context_management", "frequency_penalty", - "include", "logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "n", "presence_penalty", - "reasoning", - "reasoning_effort", - "seed", "service_tier", "stop", "stop_sequences", - "store", "temperature", "top_logprobs", "top_p", - "truncation", "verbosity", } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to ``ChatOpenAI``. +_CHAT_OPENAI_MAPPING_KEYS = frozenset({"logit_bias"}) + #: Accepted by ``ChatOpenAI`` but never forwarded, and why: #: * ``api_key``, ``openai_api_key``, ``organization``, ``openai_organization``: credentials. #: * ``base_url``, ``openai_api_base``, ``openai_proxy``: where requests go. @@ -117,9 +115,17 @@ #: ``extra_headers``/``extra_query`` or any other excluded key past this list. #: * ``max_retries``, ``request_timeout``, ``timeout``, ``stream_chunk_timeout``: retries and #: timeouts. -#: * ``stream_usage``, ``include_response_headers``, ``disabled_params``, ``tiktoken_model_name``, -#: ``use_responses_api``, ``use_previous_response_id``: which API and response shape the handler -#: gets back, and how usage is reported to it. +#: * ``use_responses_api``, ``use_previous_response_id``, ``include``, ``reasoning``, +#: ``truncation``, ``context_management``: API-shape switch. Each one makes ``ChatOpenAI`` call +#: the Responses API instead of Chat Completions, which changes the API and response shape the +#: handler gets back. ``truncation``, ``context_management`` and ``use_previous_response_id`` +#: also lean on server-side conversation state. +#: * ``reasoning_effort``: the Chat Completions spelling of ``reasoning``, left out with it so a +#: config cannot set reasoning by one spelling and not the other. +#: * ``store``: data retention. It decides whether the request is kept on the server. +#: * ``seed``: not on the cross-SDK list. +#: * ``stream_usage``, ``include_response_headers``, ``disabled_params``, ``tiktoken_model_name``: +#: runtime wiring. They change what the handler gets back and how usage is reported to it. #: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. #: #: Named for the drift test and for review, not read at runtime: the forwarded list above already @@ -153,15 +159,23 @@ "tiktoken_model_name", "use_responses_api", "use_previous_response_id", + "include", + "reasoning", + "truncation", + "context_management", + "reasoning_effort", + "store", + "seed", } #: Every key ``ChatAnthropic`` accepts (field names plus pydantic aliases), classified the same way #: as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``TestChatAnthropicAcceptsExactlyTheseKeys`` in this #: package's tests asserts this classification stays exhaustive. +#: +#: This is the cross-SDK list for LangChain ChatAnthropic (TESTING.md section 1.12). _CHAT_ANTHROPIC_FORWARDED_KEYS = frozenset( { "betas", - "context_management", "effort", "max_tokens", "max_tokens_to_sample", @@ -175,10 +189,16 @@ } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to ``ChatAnthropic``. +_CHAT_ANTHROPIC_MAPPING_KEYS = frozenset({"output_config", "thinking"}) + #: Accepted by ``ChatAnthropic`` but never forwarded, and why: #: * ``anthropic_api_key``, ``api_key``: credentials (field plus alias). #: * ``anthropic_api_url``, ``base_url``, ``anthropic_proxy``: where requests go. #: * ``inference_geo``: the region inference runs in, which decides where data is processed. +#: * ``context_management``: server-side state. It has the server clear or compact earlier +#: context. #: * ``default_headers``, ``model_kwargs``: raw request injection. ``model_kwargs`` is merged #: straight into the request, so it would carry any excluded key past this list. #: * ``mcp_servers``: attaches remote MCP servers, which then receive the conversation. @@ -196,6 +216,7 @@ "base_url", "anthropic_proxy", "inference_geo", + "context_management", "default_headers", "model_kwargs", "mcp_servers", @@ -211,25 +232,22 @@ #: package (Bedrock support is opt-in, see ``_make_default_chat_model``), so #: ``TestChatBedrockConverseAcceptsExactlyTheseKeys`` in this package's tests skips itself when it #: is not installed rather than asserting nothing. +#: +#: This is the cross-SDK list for LangChain ChatBedrockConverse (TESTING.md section 1.12). _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = frozenset( { - "guard_last_turn_only", - "guardrail_config", - "guardrails", "max_tokens", - "output_config", "performance_config", - "reasoning_effort", - "request_metadata", "service_tier", - "stop", - "stop_sequences", - "system", "temperature", "top_p", } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to ``ChatBedrockConverse``. +_CHAT_BEDROCK_CONVERSE_MAPPING_KEYS = frozenset({"performance_config"}) + #: Accepted by ``ChatBedrockConverse`` but never forwarded, and why: #: * ``bedrock_api_key``, ``api_key``, ``aws_access_key_id``, ``aws_secret_access_key``, #: ``aws_session_token``, ``credentials_profile_name``: credentials. @@ -243,6 +261,17 @@ #: same identity as ``model_id`` (handler-owned). #: * ``additional_model_response_field_paths``, ``raw_blocks``, ``supports_tool_choice_values``: #: the response shape and tool-calling behaviour the handler relies on. +#: * ``guardrails``, ``guardrail_config``, ``guard_last_turn_only``: safety configuration. +#: * ``request_metadata``: identity and attribution. It tags the request for whoever reads the +#: invocation logs. +#: * ``system``: prompt content beyond instructions. It adds system prompt blocks on top of the +#: config's own instructions. +#: * ``output_config``: API-shape switch. It is how ``ChatBedrockConverse`` asks for structured +#: output, which changes the response the handler reads. +#: * ``reasoning_effort``: runtime wiring. ``ChatBedrockConverse`` turns it into model-specific +#: ``additionalModelRequestFields``, the raw request fields the handler never sets otherwise. +#: * ``stop``, ``stop_sequences``: runtime wiring. ``ChatBedrockConverse`` merges them with the +#: stop sequences its own structured-output prompt relies on. #: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. #: #: Named for the drift test and for review, not read at runtime. @@ -269,6 +298,15 @@ "additional_model_response_field_paths", "raw_blocks", "supports_tool_choice_values", + "guardrails", + "guardrail_config", + "guard_last_turn_only", + "request_metadata", + "system", + "output_config", + "reasoning_effort", + "stop", + "stop_sequences", } @@ -407,8 +445,12 @@ def _model_constructor_kwargs( config: AiConfigRep, fallback_name: str, forwarded_keys: frozenset[str], + *, + mapping_keys: frozenset[str] = frozenset(), ) -> dict[str, Any]: - parameters = select_forwarded_parameters(model_parameters(config), forwarded_keys) + parameters = select_forwarded_parameters( + model_parameters(config), forwarded_keys, mapping_keys=mapping_keys + ) parameters["model"] = _resolved_model_name(config, fallback_name) return parameters @@ -423,7 +465,7 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: Instantiate the appropriate LangChain chat model based on ``config.provider.name``. Falls back to ``ChatOpenAI`` when the provider is not recognised. Requires the matching ``langchain-`` integration package to be installed. - ``model.parameters`` are passed through unchanged. + ``model.parameters`` are filtered to that class's forwarded list first. """ import importlib @@ -432,7 +474,10 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: lc_anthropic = importlib.import_module("langchain_anthropic") return lc_anthropic.ChatAnthropic( **_model_constructor_kwargs( - config, "claude-3-5-sonnet-20241022", _CHAT_ANTHROPIC_FORWARDED_KEYS + config, + "claude-3-5-sonnet-20241022", + _CHAT_ANTHROPIC_FORWARDED_KEYS, + mapping_keys=_CHAT_ANTHROPIC_MAPPING_KEYS, ) ) if provider == "bedrock": @@ -445,12 +490,20 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: ) from exc return lc_aws.ChatBedrockConverse( **_model_constructor_kwargs( - config, "", _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS + config, + "", + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, + mapping_keys=_CHAT_BEDROCK_CONVERSE_MAPPING_KEYS, ) ) lc_openai = importlib.import_module("langchain_openai") return lc_openai.ChatOpenAI( - **_model_constructor_kwargs(config, "gpt-4o", _CHAT_OPENAI_FORWARDED_KEYS) + **_model_constructor_kwargs( + config, + "gpt-4o", + _CHAT_OPENAI_FORWARDED_KEYS, + mapping_keys=_CHAT_OPENAI_MAPPING_KEYS, + ) ) diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py index 6fc4a24d..6425f27c 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/native_graph.py @@ -25,7 +25,7 @@ from launchdarkly_ai_server.parameter_forwarding import select_forwarded_parameters from launchdarkly_ai_server.utils import model_parameters -from .handler import _CHAT_OPENAI_FORWARDED_KEYS +from .handler import _CHAT_OPENAI_FORWARDED_KEYS, _CHAT_OPENAI_MAPPING_KEYS from .messages import to_lang_chain_messages try: @@ -212,7 +212,9 @@ async def _traverse_node(node: GraphNode) -> None: kwargs = model_parameters(node.config) if kwargs: kwargs = select_forwarded_parameters( - kwargs, _CHAT_OPENAI_FORWARDED_KEYS + kwargs, + _CHAT_OPENAI_FORWARDED_KEYS, + mapping_keys=_CHAT_OPENAI_MAPPING_KEYS, ) kwargs["model"] = model_cfg.get("name") or "gpt-4o" chat_model = lc_openai.ChatOpenAI(**kwargs) diff --git a/packages/langchain-agents/tests/test_native_graph.py b/packages/langchain-agents/tests/test_native_graph.py index 4dc6c51f..47918224 100644 --- a/packages/langchain-agents/tests/test_native_graph.py +++ b/packages/langchain-agents/tests/test_native_graph.py @@ -20,6 +20,12 @@ to_lang_graph, ) from launchdarkly_ai_server import GraphDefinition, GraphEdge, GraphNode +from tests.forwarding_spec import ( + LANGCHAIN_CHAT_OPENAI, + LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + candidate_keys, + probe_forwarded_keys, +) from tests.never_forwarded import NEVER_FORWARDED_BAG # --------------------------------------------------------------------------- @@ -1047,3 +1053,40 @@ async def _visit(fn: Any, ctx: Any = None) -> None: "temperature": 0.2, "model": "gpt-4o", } + + +class TestNativeGraphForwardsExactlyTheCrossSdkList: + """The native graph's default ``ChatOpenAI`` takes the same forwarded list as the handler's, + so a probe of one node finds exactly the cross-SDK ChatOpenAI list.""" + + @pytest.mark.asyncio + async def test_default_chat_openai(self) -> None: + async def call(parameters: dict[str, Any]) -> object: + mocks = _make_langgraph_mocks(_make_ai_msg("final")) + graph_def = _make_graph_def( + nodes={ + "root": { + "key": "root", + "config": { + "model": {"name": "gpt-4o", "parameters": parameters}, + "instructions": "help", + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + ) + + async def _visit(fn: Any, ctx: Any = None) -> None: + if graph_def.root is not None: + await fn(graph_def.root) + + graph_def.traverse = _visit + with _patch_imports(mocks): + await to_lang_graph(_make_def_promise(graph_def)).invoke("hi") + return mocks["langchain_openai"].ChatOpenAI.call_args.kwargs + + expected = LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED + candidates = candidate_keys(_REAL_CHAT_OPENAI.model_fields, expected) + assert await probe_forwarded_keys(candidates, call) == expected diff --git a/packages/langchain-agents/tests/test_parameter_forwarding.py b/packages/langchain-agents/tests/test_parameter_forwarding.py index 9e19cff6..a0d255e4 100644 --- a/packages/langchain-agents/tests/test_parameter_forwarding.py +++ b/packages/langchain-agents/tests/test_parameter_forwarding.py @@ -13,8 +13,10 @@ from __future__ import annotations +import importlib from collections.abc import Mapping from typing import Any, ClassVar +from unittest.mock import MagicMock, patch import langchain_anthropic import langchain_openai @@ -30,8 +32,18 @@ _CHAT_OPENAI_EXCLUDED_KEYS, _CHAT_OPENAI_FORWARDED_KEYS, _CHAT_OPENAI_OWNED_KEYS, + _make_default_chat_model, _model_constructor_kwargs, ) +from tests.forwarding_spec import ( + LANGCHAIN_CHAT_ANTHROPIC, + LANGCHAIN_CHAT_BEDROCK_CONVERSE, + LANGCHAIN_CHAT_OPENAI, + LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + candidate_keys, + probe_forwarded_keys, + sample, +) from tests.never_forwarded import NEVER_FORWARDED_BAG @@ -248,3 +260,145 @@ def test_forwarded_model_parameters_keep_them_out_of_the_payload(self) -> None: payload = self._payload(**kwargs) assert "extra_headers" not in payload assert "extra_query" not in payload + + +def _probe_config(provider: str, parameters: dict[str, Any]) -> Any: + return { + "model": {"name": "configured-model", "parameters": parameters}, + "provider": {"name": provider}, + } + + +def _build(provider: str, parameters: dict[str, Any]) -> dict[str, Any]: + """Runs ``_make_default_chat_model`` for *provider* against a recording constructor and + returns the kwargs it passed.""" + ctor = MagicMock() + module = MagicMock(ChatOpenAI=ctor, ChatAnthropic=ctor, ChatBedrockConverse=ctor) + with patch("importlib.import_module", return_value=module): + _make_default_chat_model(_probe_config(provider, parameters)) + kwargs: dict[str, Any] = ctor.call_args.kwargs + return kwargs + + +def _accepted_or_empty(module_name: str, cls_name: str) -> frozenset[str]: + try: + module = importlib.import_module(module_name) + except ImportError: + return frozenset() + return _accepted_keys(getattr(module, cls_name)) + + +class TestForwardsExactlyTheCrossSdkList: + """Probes each chat model constructor one key at a time: the keys that change what it is + built with are exactly the cross-SDK list for that class. ``invoke`` and ``stream`` both + build the model through ``_make_default_chat_model``, so this covers both paths.""" + + @pytest.mark.parametrize( + ("provider", "module_name", "cls_name", "expected"), + [ + ( + "openai", + "langchain_openai", + "ChatOpenAI", + LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + ), + ( + "anthropic", + "langchain_anthropic", + "ChatAnthropic", + LANGCHAIN_CHAT_ANTHROPIC, + ), + ( + "bedrock", + "langchain_aws", + "ChatBedrockConverse", + LANGCHAIN_CHAT_BEDROCK_CONVERSE, + ), + ], + ) + async def test_forwarded_keys( + self, provider: str, module_name: str, cls_name: str, expected: frozenset[str] + ) -> None: + baseline = _build(provider, {}) + + async def call(parameters: dict[str, Any]) -> object: + return _build(provider, parameters) + + candidates = candidate_keys(_accepted_or_empty(module_name, cls_name), expected) + assert await probe_forwarded_keys(candidates, call) == expected + assert baseline == {"model": "configured-model"} + + def test_chat_openai_has_no_prompt_cache_key_field(self) -> None: + """Why ``prompt_cache_key`` is left off: ``ChatOpenAI`` cannot take it. When it can, + this fails and the key goes on the list.""" + assert not LANGCHAIN_CHAT_OPENAI_UNSUPPORTED & _accepted_keys( + langchain_openai.ChatOpenAI + ) + + @pytest.mark.parametrize( + ("cls", "keys"), + [ + ( + langchain_openai.ChatOpenAI, + LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + ), + (langchain_anthropic.ChatAnthropic, LANGCHAIN_CHAT_ANTHROPIC), + ], + ) + def test_the_real_class_accepts_every_forwarded_key( + self, cls: Any, keys: frozenset[str] + ) -> None: + for key in sorted(keys): + cls(model="m", api_key="sk-test-not-a-real-key", **{key: sample(key)}) + + +class TestMalformedObjectValuesAreDropped: + @pytest.mark.parametrize( + ("provider", "parameters"), + [ + ("openai", {"logit_bias": "none"}), + ("anthropic", {"thinking": "enabled", "output_config": "json"}), + ("bedrock", {"performance_config": "optimized"}), + ], + ) + def test_dropped(self, provider: str, parameters: dict[str, Any]) -> None: + kwargs = _build(provider, {**parameters, "temperature": 0.2}) + assert kwargs == {"model": "configured-model", "temperature": 0.2} + + +class _Built(Exception): + """Stops a run once the chat model has been built.""" + + +class TestInvokeAndStreamBuildTheSameModel: + """Both paths hand the same config to ``_make_default_chat_model``, so the probe above + covers both.""" + + async def test_same_config_reaches_the_builder(self) -> None: + import launchdarkly_ai_langchain_agents.handler as handler_mod + from launchdarkly_ai_langchain_agents import create_langchain_agents_handler + + config = { + "model": { + "name": "gpt-4o", + "parameters": {**NEVER_FORWARDED_BAG, "temperature": 0.2, "top_p": 0.5}, + }, + "provider": {"name": "openai"}, + "instructions": "help", + } + seen: list[Any] = [] + + def _builder(cfg: Any, *_rest: Any) -> Any: + seen.append(cfg) + raise _Built + + with patch.object(handler_mod, "_make_default_chat_model", _builder): + h = create_langchain_agents_handler() + with pytest.raises(_Built): + await h(config, "q") + with pytest.raises(_Built): + async for _event in await h.stream(config, "q"): + pass + + assert len(seen) == 2 + assert seen[0] == seen[1] 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 4bcd0b3f..f19e152c 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -82,33 +82,31 @@ #: ``TestChatOpenAIAcceptsExactlyTheseKeys`` in this package's tests asserts this classification #: stays exhaustive as the SDK's own pydantic model changes. #: -#: Only model request settings are forwarded. +#: This is the cross-SDK list for LangChain ChatOpenAI (TESTING.md section 1.12), less +#: ``prompt_cache_key``: ``ChatOpenAI`` has no such field, so the handler cannot set it. _CHAT_OPENAI_FORWARDED_KEYS = frozenset( { - "context_management", "frequency_penalty", - "include", "logit_bias", "logprobs", "max_completion_tokens", "max_tokens", "n", "presence_penalty", - "reasoning", - "reasoning_effort", - "seed", "service_tier", "stop", "stop_sequences", - "store", "temperature", "top_logprobs", "top_p", - "truncation", "verbosity", } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to ``ChatOpenAI``. +_CHAT_OPENAI_MAPPING_KEYS = frozenset({"logit_bias"}) + #: Accepted by ``ChatOpenAI`` but never forwarded, and why: #: * ``api_key``, ``openai_api_key``, ``organization``, ``openai_organization``: credentials. #: * ``base_url``, ``openai_api_base``, ``openai_proxy``: where requests go. @@ -119,9 +117,17 @@ #: ``extra_headers``/``extra_query`` or any other excluded key past this list. #: * ``max_retries``, ``request_timeout``, ``timeout``, ``stream_chunk_timeout``: retries and #: timeouts. -#: * ``stream_usage``, ``include_response_headers``, ``disabled_params``, ``tiktoken_model_name``, -#: ``use_responses_api``, ``use_previous_response_id``: which API and response shape the handler -#: gets back, and how usage is reported to it. +#: * ``use_responses_api``, ``use_previous_response_id``, ``include``, ``reasoning``, +#: ``truncation``, ``context_management``: API-shape switch. Each one makes ``ChatOpenAI`` call +#: the Responses API instead of Chat Completions, which changes the API and response shape the +#: handler gets back. ``truncation``, ``context_management`` and ``use_previous_response_id`` +#: also lean on server-side conversation state. +#: * ``reasoning_effort``: the Chat Completions spelling of ``reasoning``, left out with it so a +#: config cannot set reasoning by one spelling and not the other. +#: * ``store``: data retention. It decides whether the request is kept on the server. +#: * ``seed``: not on the cross-SDK list. +#: * ``stream_usage``, ``include_response_headers``, ``disabled_params``, ``tiktoken_model_name``: +#: runtime wiring. They change what the handler gets back and how usage is reported to it. #: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. #: #: Named for the drift test and for review, not read at runtime: the forwarded list above already @@ -155,15 +161,23 @@ "tiktoken_model_name", "use_responses_api", "use_previous_response_id", + "include", + "reasoning", + "truncation", + "context_management", + "reasoning_effort", + "store", + "seed", } #: Every key ``ChatAnthropic`` accepts (field names plus pydantic aliases), classified the same way #: as :data:`_CHAT_OPENAI_FORWARDED_KEYS`. ``TestChatAnthropicAcceptsExactlyTheseKeys`` in this #: package's tests asserts this classification stays exhaustive. +#: +#: This is the cross-SDK list for LangChain ChatAnthropic (TESTING.md section 1.12). _CHAT_ANTHROPIC_FORWARDED_KEYS = frozenset( { "betas", - "context_management", "effort", "max_tokens", "max_tokens_to_sample", @@ -177,10 +191,16 @@ } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to ``ChatAnthropic``. +_CHAT_ANTHROPIC_MAPPING_KEYS = frozenset({"output_config", "thinking"}) + #: Accepted by ``ChatAnthropic`` but never forwarded, and why: #: * ``anthropic_api_key``, ``api_key``: credentials (field plus alias). #: * ``anthropic_api_url``, ``base_url``, ``anthropic_proxy``: where requests go. #: * ``inference_geo``: the region inference runs in, which decides where data is processed. +#: * ``context_management``: server-side state. It has the server clear or compact earlier +#: context. #: * ``default_headers``, ``model_kwargs``: raw request injection. ``model_kwargs`` is merged #: straight into the request, so it would carry any excluded key past this list. #: * ``mcp_servers``: attaches remote MCP servers, which then receive the conversation. @@ -198,6 +218,7 @@ "base_url", "anthropic_proxy", "inference_geo", + "context_management", "default_headers", "model_kwargs", "mcp_servers", @@ -213,25 +234,22 @@ #: package (Bedrock support is opt-in, see ``_make_default_chat_model``), so #: ``TestChatBedrockConverseAcceptsExactlyTheseKeys`` in this package's tests skips itself when it #: is not installed rather than asserting nothing. +#: +#: This is the cross-SDK list for LangChain ChatBedrockConverse (TESTING.md section 1.12). _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS = frozenset( { - "guard_last_turn_only", - "guardrail_config", - "guardrails", "max_tokens", - "output_config", "performance_config", - "reasoning_effort", - "request_metadata", "service_tier", - "stop", - "stop_sequences", - "system", "temperature", "top_p", } ) +#: Forwarded keys whose value must be an object. A config value of any other type is malformed +#: and dropped rather than passed to ``ChatBedrockConverse``. +_CHAT_BEDROCK_CONVERSE_MAPPING_KEYS = frozenset({"performance_config"}) + #: Accepted by ``ChatBedrockConverse`` but never forwarded, and why: #: * ``bedrock_api_key``, ``api_key``, ``aws_access_key_id``, ``aws_secret_access_key``, #: ``aws_session_token``, ``credentials_profile_name``: credentials. @@ -245,6 +263,17 @@ #: same identity as ``model_id`` (handler-owned). #: * ``additional_model_response_field_paths``, ``raw_blocks``, ``supports_tool_choice_values``: #: the response shape and tool-calling behaviour the handler relies on. +#: * ``guardrails``, ``guardrail_config``, ``guard_last_turn_only``: safety configuration. +#: * ``request_metadata``: identity and attribution. It tags the request for whoever reads the +#: invocation logs. +#: * ``system``: prompt content beyond instructions. It adds system prompt blocks on top of the +#: config's own instructions. +#: * ``output_config``: API-shape switch. It is how ``ChatBedrockConverse`` asks for structured +#: output, which changes the response the handler reads. +#: * ``reasoning_effort``: runtime wiring. ``ChatBedrockConverse`` turns it into model-specific +#: ``additionalModelRequestFields``, the raw request fields the handler never sets otherwise. +#: * ``stop``, ``stop_sequences``: runtime wiring. ``ChatBedrockConverse`` merges them with the +#: stop sequences its own structured-output prompt relies on. #: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. #: #: Named for the drift test and for review, not read at runtime. @@ -271,6 +300,15 @@ "additional_model_response_field_paths", "raw_blocks", "supports_tool_choice_values", + "guardrails", + "guardrail_config", + "guard_last_turn_only", + "request_metadata", + "system", + "output_config", + "reasoning_effort", + "stop", + "stop_sequences", } @@ -457,9 +495,12 @@ def _model_constructor_kwargs( config: AiConfigRep, fallback_name: str, forwarded_keys: frozenset[str], + *, + mapping_keys: frozenset[str] = frozenset(), ) -> dict[str, Any]: - parameters = select_forwarded_parameters(model_parameters(config), forwarded_keys) - # Name from the config always wins over a colliding ``model`` key in the parameter bag. + parameters = select_forwarded_parameters( + model_parameters(config), forwarded_keys, mapping_keys=mapping_keys + ) parameters["model"] = _resolved_model_name(config, fallback_name) return parameters @@ -474,14 +515,17 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: Instantiate the appropriate LangChain chat model based on ``config.provider.name``. Falls back to ``ChatOpenAI`` when the provider is not recognised. Requires the matching ``langchain-`` integration package to be installed. - ``model.parameters`` are passed through unchanged. + ``model.parameters`` are filtered to that class's forwarded list first. """ provider = config.get("provider", {}).get("name", "openai").lower() if provider == "anthropic": lc_anthropic = importlib.import_module("langchain_anthropic") return lc_anthropic.ChatAnthropic( **_model_constructor_kwargs( - config, "claude-3-5-sonnet-20241022", _CHAT_ANTHROPIC_FORWARDED_KEYS + config, + "claude-3-5-sonnet-20241022", + _CHAT_ANTHROPIC_FORWARDED_KEYS, + mapping_keys=_CHAT_ANTHROPIC_MAPPING_KEYS, ) ) if provider == "bedrock": @@ -494,12 +538,20 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: ) from exc return lc_aws.ChatBedrockConverse( **_model_constructor_kwargs( - config, "", _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS + config, + "", + _CHAT_BEDROCK_CONVERSE_FORWARDED_KEYS, + mapping_keys=_CHAT_BEDROCK_CONVERSE_MAPPING_KEYS, ) ) lc_openai = importlib.import_module("langchain_openai") return lc_openai.ChatOpenAI( - **_model_constructor_kwargs(config, "gpt-4o", _CHAT_OPENAI_FORWARDED_KEYS) + **_model_constructor_kwargs( + config, + "gpt-4o", + _CHAT_OPENAI_FORWARDED_KEYS, + mapping_keys=_CHAT_OPENAI_MAPPING_KEYS, + ) ) diff --git a/packages/langchain-messages/tests/test_parameter_forwarding.py b/packages/langchain-messages/tests/test_parameter_forwarding.py index 3e079b30..be29235e 100644 --- a/packages/langchain-messages/tests/test_parameter_forwarding.py +++ b/packages/langchain-messages/tests/test_parameter_forwarding.py @@ -13,8 +13,10 @@ from __future__ import annotations +import importlib from collections.abc import Mapping from typing import Any, ClassVar +from unittest.mock import MagicMock, patch import langchain_anthropic import langchain_openai @@ -30,8 +32,18 @@ _CHAT_OPENAI_EXCLUDED_KEYS, _CHAT_OPENAI_FORWARDED_KEYS, _CHAT_OPENAI_OWNED_KEYS, + _make_default_chat_model, _model_constructor_kwargs, ) +from tests.forwarding_spec import ( + LANGCHAIN_CHAT_ANTHROPIC, + LANGCHAIN_CHAT_BEDROCK_CONVERSE, + LANGCHAIN_CHAT_OPENAI, + LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + candidate_keys, + probe_forwarded_keys, + sample, +) from tests.never_forwarded import NEVER_FORWARDED_BAG @@ -248,3 +260,146 @@ def test_forwarded_model_parameters_keep_them_out_of_the_payload(self) -> None: payload = self._payload(**kwargs) assert "extra_headers" not in payload assert "extra_query" not in payload + + +def _probe_config(provider: str, parameters: dict[str, Any]) -> Any: + return { + "model": {"name": "configured-model", "parameters": parameters}, + "provider": {"name": provider}, + } + + +def _build(provider: str, parameters: dict[str, Any]) -> dict[str, Any]: + """Runs ``_make_default_chat_model`` for *provider* against a recording constructor and + returns the kwargs it passed.""" + ctor = MagicMock() + module = MagicMock(ChatOpenAI=ctor, ChatAnthropic=ctor, ChatBedrockConverse=ctor) + importlib = MagicMock() + importlib.import_module.return_value = module + _make_default_chat_model(_probe_config(provider, parameters), importlib) + kwargs: dict[str, Any] = ctor.call_args.kwargs + return kwargs + + +def _accepted_or_empty(module_name: str, cls_name: str) -> frozenset[str]: + try: + module = importlib.import_module(module_name) + except ImportError: + return frozenset() + return _accepted_keys(getattr(module, cls_name)) + + +class TestForwardsExactlyTheCrossSdkList: + """Probes each chat model constructor one key at a time: the keys that change what it is + built with are exactly the cross-SDK list for that class. ``invoke`` and ``stream`` both + build the model through ``_make_default_chat_model``, so this covers both paths.""" + + @pytest.mark.parametrize( + ("provider", "module_name", "cls_name", "expected"), + [ + ( + "openai", + "langchain_openai", + "ChatOpenAI", + LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + ), + ( + "anthropic", + "langchain_anthropic", + "ChatAnthropic", + LANGCHAIN_CHAT_ANTHROPIC, + ), + ( + "bedrock", + "langchain_aws", + "ChatBedrockConverse", + LANGCHAIN_CHAT_BEDROCK_CONVERSE, + ), + ], + ) + async def test_forwarded_keys( + self, provider: str, module_name: str, cls_name: str, expected: frozenset[str] + ) -> None: + baseline = _build(provider, {}) + + async def call(parameters: dict[str, Any]) -> object: + return _build(provider, parameters) + + candidates = candidate_keys(_accepted_or_empty(module_name, cls_name), expected) + assert await probe_forwarded_keys(candidates, call) == expected + assert baseline == {"model": "configured-model"} + + def test_chat_openai_has_no_prompt_cache_key_field(self) -> None: + """Why ``prompt_cache_key`` is left off: ``ChatOpenAI`` cannot take it. When it can, + this fails and the key goes on the list.""" + assert not LANGCHAIN_CHAT_OPENAI_UNSUPPORTED & _accepted_keys( + langchain_openai.ChatOpenAI + ) + + @pytest.mark.parametrize( + ("cls", "keys"), + [ + ( + langchain_openai.ChatOpenAI, + LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + ), + (langchain_anthropic.ChatAnthropic, LANGCHAIN_CHAT_ANTHROPIC), + ], + ) + def test_the_real_class_accepts_every_forwarded_key( + self, cls: Any, keys: frozenset[str] + ) -> None: + for key in sorted(keys): + cls(model="m", api_key="sk-test-not-a-real-key", **{key: sample(key)}) + + +class TestMalformedObjectValuesAreDropped: + @pytest.mark.parametrize( + ("provider", "parameters"), + [ + ("openai", {"logit_bias": "none"}), + ("anthropic", {"thinking": "enabled", "output_config": "json"}), + ("bedrock", {"performance_config": "optimized"}), + ], + ) + def test_dropped(self, provider: str, parameters: dict[str, Any]) -> None: + kwargs = _build(provider, {**parameters, "temperature": 0.2}) + assert kwargs == {"model": "configured-model", "temperature": 0.2} + + +class _Built(Exception): + """Stops a run once the chat model has been built.""" + + +class TestInvokeAndStreamBuildTheSameModel: + """Both paths hand the same config to ``_make_default_chat_model``, so the probe above + covers both.""" + + async def test_same_config_reaches_the_builder(self) -> None: + import launchdarkly_ai_langchain_messages.handler as handler_mod + from launchdarkly_ai_langchain_messages import create_langchain_messages_handler + + config = { + "model": { + "name": "gpt-4o", + "parameters": {**NEVER_FORWARDED_BAG, "temperature": 0.2, "top_p": 0.5}, + }, + "provider": {"name": "openai"}, + "instructions": "help", + } + seen: list[Any] = [] + + def _builder(cfg: Any, *_rest: Any) -> Any: + seen.append(cfg) + raise _Built + + with patch.object(handler_mod, "_make_default_chat_model", _builder): + h = create_langchain_messages_handler() + with pytest.raises(_Built): + await h(config, "q") + with pytest.raises(_Built): + async for _event in await h.stream(config, "q"): + pass + + assert len(seen) == 2 + assert seen[0] == seen[1] From 4e92432275384525d02c2f39f71cfe45f6a9cecc Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:06:50 -0400 Subject: [PATCH 22/26] test: add the newly excluded categories to the never-forwarded bag The bag now also holds data retention (store, prompt_cache_retention), server-side state (context_management, truncation), identity and attribution (metadata, user, safety_identifier, request_metadata), API-shape switches (include, include_usage, response_include), prompt content (instructions, system) and safety configuration (moderation, guardrails, guardrail_config, guard_last_turn_only). No handler forwards any of them. Claude Messages sets system itself, so its bag test allows the key and still checks no config value reaches it. --- .../claude-messages/tests/test_handler.py | 13 ++++++--- tests/never_forwarded.py | 29 +++++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/claude-messages/tests/test_handler.py b/packages/claude-messages/tests/test_handler.py index eef83e7b..2f62c9b5 100644 --- a/packages/claude-messages/tests/test_handler.py +++ b/packages/claude-messages/tests/test_handler.py @@ -2556,9 +2556,14 @@ def _explode_on_the_chat_span(span: Any, capture: bool, **kw: Any) -> None: assert "launchdarkly.stream.abandoned" not in chat.attributes +#: Never-forwarded keys the handler sets itself from the config's instructions. ``find_leaks`` +#: still proves the config's own value for them never arrives. +_HANDLER_SETS = frozenset({"system"}) + + class TestNeverForwardedParameters: - """No credential, endpoint, request-injection, remote-tool, or host-process key in - ``model.parameters`` reaches ``messages.create`` or ``messages.stream``.""" + """No key in the never-forwarded bag reaches ``messages.create`` or ``messages.stream`` from + ``model.parameters``.""" async def test_invoke_forwards_none_of_them( self, mock_anthropic: MagicMock @@ -2572,7 +2577,7 @@ async def test_invoke_forwards_none_of_them( await create_claude_messages_handler()(config, "q", {}, {}) call_kwargs = mock_anthropic.messages.create.call_args.kwargs assert not find_leaks(call_kwargs) - assert not set(call_kwargs) & NEVER_FORWARDED_KEYS + assert not set(call_kwargs) & (NEVER_FORWARDED_KEYS - _HANDLER_SETS) async def test_stream_forwards_none_of_them( self, mock_anthropic: MagicMock @@ -2592,7 +2597,7 @@ async def test_stream_forwards_none_of_them( pass call_kwargs = mock_anthropic.messages.stream.call_args.kwargs assert not find_leaks(call_kwargs) - assert not set(call_kwargs) & NEVER_FORWARDED_KEYS + assert not set(call_kwargs) & (NEVER_FORWARDED_KEYS - _HANDLER_SETS) class TestInvokeAndStreamForwardTheSameKeys: diff --git a/tests/never_forwarded.py b/tests/never_forwarded.py index 24a34e8a..f965209b 100644 --- a/tests/never_forwarded.py +++ b/tests/never_forwarded.py @@ -9,8 +9,10 @@ from __future__ import annotations -#: Credentials, endpoints and connection settings, raw request injection, remote tools, and the -#: Claude Agents host-process settings. +#: Credentials, endpoints and connection settings, raw request injection, remote tools, the Claude +#: Agents host-process settings, and the categories no cross-SDK allowlist (TESTING.md section 1.12) +#: may hold: data retention, server-side state, identity and attribution, runtime wiring and +#: API-shape switches, prompt content beyond the instructions, and safety configuration. NEVER_FORWARDED_KEYS = frozenset( { # Credentials. @@ -61,6 +63,29 @@ "additional_model_request_fields", # Remote tools. "mcp_servers", + # Data retention. + "store", + "prompt_cache_retention", + # Server-side state. + "context_management", + "truncation", + # Identity and attribution. + "metadata", + "user", + "safety_identifier", + "request_metadata", + # Runtime wiring and API-shape switches. + "include", + "include_usage", + "response_include", + # Prompt content beyond the instructions. + "instructions", + "system", + # Safety configuration. + "moderation", + "guardrails", + "guardrail_config", + "guard_last_turn_only", # Claude Agents host-process settings. "cli_path", "env", From 8c1f9f70db54c338ce7466ce0acad25de240b0ed Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:13:47 -0400 Subject: [PATCH 23/26] fix(openai-messages): stop forwarding max_tool_calls The JS openai SDK's responses.create has no max_tool_calls parameter, so the key is cut from the cross-SDK OpenAI Messages list rather than worked around in one language. It moves to the excluded side with that reason, and the exact-set test follows the shorter list. --- .../src/launchdarkly_ai_openai_messages/handler.py | 4 +++- tests/forwarding_spec.py | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index 5dea157c..4923a228 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -68,7 +68,6 @@ _RESPONSES_FORWARDED_KEYS = frozenset( { "max_output_tokens", - "max_tool_calls", "parallel_tool_calls", "prompt_cache_key", "reasoning", @@ -104,6 +103,8 @@ #: builds the input from the config's own instructions and messages, and a server-side prompt #: template or a second instructions string would replace or add to it. #: * ``moderation``: safety configuration. +#: * ``max_tool_calls``: cut from the cross-SDK list, because the JS ``openai`` SDK's +#: ``responses.create`` has no such parameter and the spec cuts a key rather than work around it. #: * ``text_format``: API-shape switch. Only ``.stream`` accepts it, and it is a Python type to #: parse into, which a config cannot express. Structured output goes through ``text``, which the #: handler owns. @@ -131,6 +132,7 @@ "prompt", "instructions", "moderation", + "max_tool_calls", "text_format", "timeout", "extra_headers", diff --git a/tests/forwarding_spec.py b/tests/forwarding_spec.py index 76e77076..fec927f3 100644 --- a/tests/forwarding_spec.py +++ b/tests/forwarding_spec.py @@ -34,7 +34,6 @@ "max_output_tokens", "max_completion_tokens", "max_tokens", - "max_tool_calls", "parallel_tool_calls", "prompt_cache_key", "reasoning", @@ -178,7 +177,6 @@ "max_thinking_tokens": 444, "max_tokens": 111, "max_tokens_to_sample": 555, - "max_tool_calls": 3, "max_turns": 4, "n": 2, "output_config": {"effort": "high"}, From 7cfb3a72e66825d9b5bac5bf5aea854a0577bc99 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:13:47 -0400 Subject: [PATCH 24/26] fix(langchain): rename max_tokens_to_sample to max_tokens for ChatAnthropic ChatAnthropic takes both spellings of one field, so a config that set both left the winner to pydantic. The alias is now renamed to max_tokens before forwarding, and a max_tokens the config also set wins. --- .../launchdarkly_ai_langchain_agents/handler.py | 17 +++++++++++++++-- .../tests/test_parameter_forwarding.py | 16 ++++++++++++++++ .../handler.py | 17 +++++++++++++++-- .../tests/test_parameter_forwarding.py | 16 ++++++++++++++++ 4 files changed, 62 insertions(+), 4 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 bbe18e5b..3b355102 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -8,7 +8,7 @@ import asyncio import inspect import json -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from typing import Any from launchdarkly_ai_server import ( @@ -173,6 +173,8 @@ #: package's tests asserts this classification stays exhaustive. #: #: This is the cross-SDK list for LangChain ChatAnthropic (TESTING.md section 1.12). +#: ``max_tokens_to_sample`` is renamed to ``max_tokens`` before this list applies, and a +#: ``max_tokens`` the config also set wins (see :data:`_CHAT_ANTHROPIC_RENAMES`). _CHAT_ANTHROPIC_FORWARDED_KEYS = frozenset( { "betas", @@ -193,6 +195,10 @@ #: and dropped rather than passed to ``ChatAnthropic``. _CHAT_ANTHROPIC_MAPPING_KEYS = frozenset({"output_config", "thinking"}) +#: Alias to the name it is renamed to before forwarding. ``ChatAnthropic`` takes both spellings +#: of the one field, so passing both would leave which one wins to pydantic. +_CHAT_ANTHROPIC_RENAMES = {"max_tokens_to_sample": "max_tokens"} + #: Accepted by ``ChatAnthropic`` but never forwarded, and why: #: * ``anthropic_api_key``, ``api_key``: credentials (field plus alias). #: * ``anthropic_api_url``, ``base_url``, ``anthropic_proxy``: where requests go. @@ -447,9 +453,15 @@ def _model_constructor_kwargs( forwarded_keys: frozenset[str], *, mapping_keys: frozenset[str] = frozenset(), + renames: Mapping[str, str] | None = None, ) -> dict[str, Any]: + params = model_parameters(config) + for alias, name in (renames or {}).items(): + if alias in params: + value = params.pop(alias) + params.setdefault(name, value) parameters = select_forwarded_parameters( - model_parameters(config), forwarded_keys, mapping_keys=mapping_keys + params, forwarded_keys, mapping_keys=mapping_keys ) parameters["model"] = _resolved_model_name(config, fallback_name) return parameters @@ -478,6 +490,7 @@ def _make_default_chat_model(config: AiConfigRep) -> Any: "claude-3-5-sonnet-20241022", _CHAT_ANTHROPIC_FORWARDED_KEYS, mapping_keys=_CHAT_ANTHROPIC_MAPPING_KEYS, + renames=_CHAT_ANTHROPIC_RENAMES, ) ) if provider == "bedrock": diff --git a/packages/langchain-agents/tests/test_parameter_forwarding.py b/packages/langchain-agents/tests/test_parameter_forwarding.py index a0d255e4..bbb65459 100644 --- a/packages/langchain-agents/tests/test_parameter_forwarding.py +++ b/packages/langchain-agents/tests/test_parameter_forwarding.py @@ -402,3 +402,19 @@ def _builder(cfg: Any, *_rest: Any) -> Any: assert len(seen) == 2 assert seen[0] == seen[1] + + +class TestMaxTokensToSampleRename: + """``ChatAnthropic`` takes ``max_tokens_to_sample`` as an alias of ``max_tokens``. It is + renamed before forwarding, and ``max_tokens`` wins when both are set.""" + + def test_alias_becomes_max_tokens(self) -> None: + assert _build("anthropic", {"max_tokens_to_sample": 300}) == { + "model": "configured-model", + "max_tokens": 300, + } + + def test_max_tokens_wins_when_both_are_set(self) -> None: + assert _build( + "anthropic", {"max_tokens_to_sample": 300, "max_tokens": 200} + ) == {"model": "configured-model", "max_tokens": 200} 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 f19e152c..b6735414 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -2,7 +2,7 @@ import asyncio import json -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping from types import SimpleNamespace from typing import Any @@ -175,6 +175,8 @@ #: package's tests asserts this classification stays exhaustive. #: #: This is the cross-SDK list for LangChain ChatAnthropic (TESTING.md section 1.12). +#: ``max_tokens_to_sample`` is renamed to ``max_tokens`` before this list applies, and a +#: ``max_tokens`` the config also set wins (see :data:`_CHAT_ANTHROPIC_RENAMES`). _CHAT_ANTHROPIC_FORWARDED_KEYS = frozenset( { "betas", @@ -195,6 +197,10 @@ #: and dropped rather than passed to ``ChatAnthropic``. _CHAT_ANTHROPIC_MAPPING_KEYS = frozenset({"output_config", "thinking"}) +#: Alias to the name it is renamed to before forwarding. ``ChatAnthropic`` takes both spellings +#: of the one field, so passing both would leave which one wins to pydantic. +_CHAT_ANTHROPIC_RENAMES = {"max_tokens_to_sample": "max_tokens"} + #: Accepted by ``ChatAnthropic`` but never forwarded, and why: #: * ``anthropic_api_key``, ``api_key``: credentials (field plus alias). #: * ``anthropic_api_url``, ``base_url``, ``anthropic_proxy``: where requests go. @@ -497,9 +503,15 @@ def _model_constructor_kwargs( forwarded_keys: frozenset[str], *, mapping_keys: frozenset[str] = frozenset(), + renames: Mapping[str, str] | None = None, ) -> dict[str, Any]: + params = model_parameters(config) + for alias, name in (renames or {}).items(): + if alias in params: + value = params.pop(alias) + params.setdefault(name, value) parameters = select_forwarded_parameters( - model_parameters(config), forwarded_keys, mapping_keys=mapping_keys + params, forwarded_keys, mapping_keys=mapping_keys ) parameters["model"] = _resolved_model_name(config, fallback_name) return parameters @@ -526,6 +538,7 @@ def _make_default_chat_model(config: AiConfigRep, importlib: Any) -> Any: "claude-3-5-sonnet-20241022", _CHAT_ANTHROPIC_FORWARDED_KEYS, mapping_keys=_CHAT_ANTHROPIC_MAPPING_KEYS, + renames=_CHAT_ANTHROPIC_RENAMES, ) ) if provider == "bedrock": diff --git a/packages/langchain-messages/tests/test_parameter_forwarding.py b/packages/langchain-messages/tests/test_parameter_forwarding.py index be29235e..3145ee46 100644 --- a/packages/langchain-messages/tests/test_parameter_forwarding.py +++ b/packages/langchain-messages/tests/test_parameter_forwarding.py @@ -403,3 +403,19 @@ def _builder(cfg: Any, *_rest: Any) -> Any: assert len(seen) == 2 assert seen[0] == seen[1] + + +class TestMaxTokensToSampleRename: + """``ChatAnthropic`` takes ``max_tokens_to_sample`` as an alias of ``max_tokens``. It is + renamed before forwarding, and ``max_tokens`` wins when both are set.""" + + def test_alias_becomes_max_tokens(self) -> None: + assert _build("anthropic", {"max_tokens_to_sample": 300}) == { + "model": "configured-model", + "max_tokens": 300, + } + + def test_max_tokens_wins_when_both_are_set(self) -> None: + assert _build( + "anthropic", {"max_tokens_to_sample": 300, "max_tokens": 200} + ) == {"model": "configured-model", "max_tokens": 200} From 52bf2d2f587efbf0746e4d2dbd374b7d8cd3594e Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:32:00 -0400 Subject: [PATCH 25/26] fix(openai-agents): rebuild reasoning as Reasoning and let text.verbosity win A forwarded reasoning object becomes the SDK's own Reasoning type with only effort and summary, the same two sub-keys the JS SDK keeps, and is dropped when it has neither. A test runs the real Chat Completions model with it. When a config sets both text.verbosity and a top-level verbosity, the explicit text.verbosity now wins, matching the JS SDK. --- .../launchdarkly_ai_openai_agents/handler.py | 36 +++++++-- packages/openai-agents/tests/test_handler.py | 77 ++++++++++++++++++- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index 6109ae85..2e2da6e0 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -136,24 +136,46 @@ def _apply_text_verbosity(params: dict[str, Any]) -> dict[str, Any]: """Moves ``text.verbosity`` to ``ModelSettings``'s top-level ``verbosity`` and drops the rest - of ``text``. A top-level ``verbosity`` the config already set wins, and a ``text`` that is not - an object is malformed and dropped. + of ``text``. An explicit ``text.verbosity`` wins over a top-level ``verbosity``, as in the JS + SDK, and a ``text`` that is not an object is malformed and dropped. """ text = params.pop("text", None) - if isinstance(text, Mapping) and "verbosity" in text and "verbosity" not in params: + if isinstance(text, Mapping) and "verbosity" in text: params["verbosity"] = text["verbosity"] return params +#: The ``reasoning`` sub-keys forwarded, the same two the JS SDK keeps. +_REASONING_KEYS = ("effort", "summary") + + +def _rebuild_reasoning(params: dict[str, Any]) -> dict[str, Any]: + """Rebuilds a forwarded ``reasoning`` object as the SDK's own ``Reasoning`` type, the type + ``ModelSettings`` declares, keeping only ``effort`` and ``summary`` as the JS SDK does. Any + other sub-key is dropped, and so is a ``reasoning`` with neither. + """ + reasoning = params.pop("reasoning", None) + if not isinstance(reasoning, Mapping): + return params + kept = {k: reasoning[k] for k in _REASONING_KEYS if k in reasoning} + if kept: + from openai.types.shared import Reasoning + + params["reasoning"] = Reasoning(**kept) + return params + + def _model_settings_parameters(config: AiConfigRep) -> dict[str, Any]: """The config's ``model.parameters`` that become ``ModelSettings`` fields. The handler and the native graph both call this, so they forward the same keys. ``max_turns`` is not among them: it is a ``Runner.run`` option, read separately for the run. """ - return select_forwarded_parameters( - _apply_text_verbosity(model_parameters(config)), - _MODEL_SETTINGS_FORWARDED_KEYS, - mapping_keys=_MODEL_SETTINGS_MAPPING_KEYS, + return _rebuild_reasoning( + select_forwarded_parameters( + _apply_text_verbosity(model_parameters(config)), + _MODEL_SETTINGS_FORWARDED_KEYS, + mapping_keys=_MODEL_SETTINGS_MAPPING_KEYS, + ) ) diff --git a/packages/openai-agents/tests/test_handler.py b/packages/openai-agents/tests/test_handler.py index d2fe18e4..d85530e7 100644 --- a/packages/openai-agents/tests/test_handler.py +++ b/packages/openai-agents/tests/test_handler.py @@ -2395,8 +2395,13 @@ def test_text_verbosity_becomes_verbosity(self) -> None: "verbosity": "low" } - def test_top_level_verbosity_wins(self) -> None: + def test_text_verbosity_wins_over_top_level_verbosity(self) -> None: assert self._settings({"verbosity": "high", "text": {"verbosity": "low"}}) == { + "verbosity": "low" + } + + def test_top_level_verbosity_applies_without_text_verbosity(self) -> None: + assert self._settings({"verbosity": "high", "text": {"format": {}}}) == { "verbosity": "high" } @@ -2409,3 +2414,73 @@ def test_reasoning_that_is_not_an_object_is_dropped(self) -> None: assert self._settings({"reasoning": "high", "temperature": 0.1}) == { "temperature": 0.1 } + + +class TestReasoningIsRebuilt: + def _settings(self, parameters: dict[str, Any]) -> dict[str, Any]: + from launchdarkly_ai_openai_agents.handler import _model_settings_parameters + + return _model_settings_parameters({"model": {"parameters": parameters}}) + + def test_becomes_the_sdk_type_with_only_effort_and_summary(self) -> None: + from openai.types.shared import Reasoning + + settings = self._settings( + {"reasoning": {"effort": "low", "summary": "auto", "generate_summary": "x"}} + ) + assert settings == {"reasoning": Reasoning(effort="low", summary="auto")} + assert settings["reasoning"].model_dump(exclude_none=True) == { + "effort": "low", + "summary": "auto", + } + + def test_reasoning_with_neither_sub_key_is_dropped(self) -> None: + assert self._settings({"reasoning": {"other": 1}, "temperature": 0.1}) == { + "temperature": 0.1 + } + + async def test_chat_completions_model_reads_it_without_raising(self) -> None: + """The Chat Completions model reads ``reasoning.effort`` as an attribute and sends it as + ``reasoning_effort``. Runs the real model against a mocked OpenAI client.""" + from agents import ModelSettings, ModelTracing + from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel + from openai.types.chat import ChatCompletion + + completion = ChatCompletion.model_validate( + { + "id": "c1", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "hi"}, + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + client = MagicMock() + client.chat.completions.create = AsyncMock(return_value=completion) + client.base_url = "https://api.openai.com/v1/" + model = OpenAIChatCompletionsModel("gpt-4o", client) + + settings = ModelSettings(**self._settings({"reasoning": {"effort": "low"}})) + await model.get_response( + system_instructions=None, + input="q", + model_settings=settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + ) + kwargs = client.chat.completions.create.call_args.kwargs + assert kwargs["reasoning_effort"] == "low" From c76acd58bad156eba8d1e0a8532b06fbc3e8ff81 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Wed, 7 Oct 2026 13:32:00 -0400 Subject: [PATCH 26/26] fix(langchain): cut prompt_cache_key from the ChatOpenAI list ChatOpenAI has no prompt_cache_key field, so the key is cut from the cross-SDK ChatOpenAI list rather than worked around. It is documented on the excluded side with that reason, and a test checks a config value never reaches the constructor. --- .../handler.py | 6 ++++-- .../tests/test_native_graph.py | 3 +-- .../tests/test_parameter_forwarding.py | 20 +++++++++---------- .../handler.py | 6 ++++-- .../tests/test_parameter_forwarding.py | 20 +++++++++---------- tests/forwarding_spec.py | 5 ----- 6 files changed, 29 insertions(+), 31 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 3b355102..f81c627b 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -80,8 +80,7 @@ #: ``TestChatOpenAIAcceptsExactlyTheseKeys`` in this package's tests asserts this classification #: stays exhaustive as the SDK's own pydantic model changes. #: -#: This is the cross-SDK list for LangChain ChatOpenAI (TESTING.md section 1.12), less -#: ``prompt_cache_key``: ``ChatOpenAI`` has no such field, so the handler cannot set it. +#: This is the cross-SDK list for LangChain ChatOpenAI (TESTING.md section 1.12). _CHAT_OPENAI_FORWARDED_KEYS = frozenset( { "frequency_penalty", @@ -124,6 +123,9 @@ #: config cannot set reasoning by one spelling and not the other. #: * ``store``: data retention. It decides whether the request is kept on the server. #: * ``seed``: not on the cross-SDK list. +#: * ``prompt_cache_key``: cut from the cross-SDK list, because ``ChatOpenAI`` has no such field +#: and the spec cuts a key rather than work around it. It is not in the set below because +#: ``ChatOpenAI`` does not accept it at all, which the drift test requires of every listed key. #: * ``stream_usage``, ``include_response_headers``, ``disabled_params``, ``tiktoken_model_name``: #: runtime wiring. They change what the handler gets back and how usage is reported to it. #: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. diff --git a/packages/langchain-agents/tests/test_native_graph.py b/packages/langchain-agents/tests/test_native_graph.py index 47918224..f8c853f9 100644 --- a/packages/langchain-agents/tests/test_native_graph.py +++ b/packages/langchain-agents/tests/test_native_graph.py @@ -22,7 +22,6 @@ from launchdarkly_ai_server import GraphDefinition, GraphEdge, GraphNode from tests.forwarding_spec import ( LANGCHAIN_CHAT_OPENAI, - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, candidate_keys, probe_forwarded_keys, ) @@ -1087,6 +1086,6 @@ async def _visit(fn: Any, ctx: Any = None) -> None: await to_lang_graph(_make_def_promise(graph_def)).invoke("hi") return mocks["langchain_openai"].ChatOpenAI.call_args.kwargs - expected = LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED + expected = LANGCHAIN_CHAT_OPENAI candidates = candidate_keys(_REAL_CHAT_OPENAI.model_fields, expected) assert await probe_forwarded_keys(candidates, call) == expected diff --git a/packages/langchain-agents/tests/test_parameter_forwarding.py b/packages/langchain-agents/tests/test_parameter_forwarding.py index bbb65459..f6d1da1c 100644 --- a/packages/langchain-agents/tests/test_parameter_forwarding.py +++ b/packages/langchain-agents/tests/test_parameter_forwarding.py @@ -39,7 +39,6 @@ LANGCHAIN_CHAT_ANTHROPIC, LANGCHAIN_CHAT_BEDROCK_CONVERSE, LANGCHAIN_CHAT_OPENAI, - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, candidate_keys, probe_forwarded_keys, sample, @@ -300,7 +299,7 @@ class TestForwardsExactlyTheCrossSdkList: "openai", "langchain_openai", "ChatOpenAI", - LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + LANGCHAIN_CHAT_OPENAI, ), ( "anthropic", @@ -328,19 +327,12 @@ async def call(parameters: dict[str, Any]) -> object: assert await probe_forwarded_keys(candidates, call) == expected assert baseline == {"model": "configured-model"} - def test_chat_openai_has_no_prompt_cache_key_field(self) -> None: - """Why ``prompt_cache_key`` is left off: ``ChatOpenAI`` cannot take it. When it can, - this fails and the key goes on the list.""" - assert not LANGCHAIN_CHAT_OPENAI_UNSUPPORTED & _accepted_keys( - langchain_openai.ChatOpenAI - ) - @pytest.mark.parametrize( ("cls", "keys"), [ ( langchain_openai.ChatOpenAI, - LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + LANGCHAIN_CHAT_OPENAI, ), (langchain_anthropic.ChatAnthropic, LANGCHAIN_CHAT_ANTHROPIC), ], @@ -418,3 +410,11 @@ def test_max_tokens_wins_when_both_are_set(self) -> None: assert _build( "anthropic", {"max_tokens_to_sample": 300, "max_tokens": 200} ) == {"model": "configured-model", "max_tokens": 200} + + +def test_prompt_cache_key_is_dropped_for_chat_openai() -> None: + """Cut from the cross-SDK ChatOpenAI list: a config value never reaches the constructor.""" + assert _build("openai", {"prompt_cache_key": "cache-1", "temperature": 0.2}) == { + "model": "configured-model", + "temperature": 0.2, + } 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 b6735414..a5c2f1b3 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -82,8 +82,7 @@ #: ``TestChatOpenAIAcceptsExactlyTheseKeys`` in this package's tests asserts this classification #: stays exhaustive as the SDK's own pydantic model changes. #: -#: This is the cross-SDK list for LangChain ChatOpenAI (TESTING.md section 1.12), less -#: ``prompt_cache_key``: ``ChatOpenAI`` has no such field, so the handler cannot set it. +#: This is the cross-SDK list for LangChain ChatOpenAI (TESTING.md section 1.12). _CHAT_OPENAI_FORWARDED_KEYS = frozenset( { "frequency_penalty", @@ -126,6 +125,9 @@ #: config cannot set reasoning by one spelling and not the other. #: * ``store``: data retention. It decides whether the request is kept on the server. #: * ``seed``: not on the cross-SDK list. +#: * ``prompt_cache_key``: cut from the cross-SDK list, because ``ChatOpenAI`` has no such field +#: and the spec cuts a key rather than work around it. It is not in the set below because +#: ``ChatOpenAI`` does not accept it at all, which the drift test requires of every listed key. #: * ``stream_usage``, ``include_response_headers``, ``disabled_params``, ``tiktoken_model_name``: #: runtime wiring. They change what the handler gets back and how usage is reported to it. #: * Everything in :data:`_LANGCHAIN_RUNTIME_KEYS`. diff --git a/packages/langchain-messages/tests/test_parameter_forwarding.py b/packages/langchain-messages/tests/test_parameter_forwarding.py index 3145ee46..79a31d4d 100644 --- a/packages/langchain-messages/tests/test_parameter_forwarding.py +++ b/packages/langchain-messages/tests/test_parameter_forwarding.py @@ -39,7 +39,6 @@ LANGCHAIN_CHAT_ANTHROPIC, LANGCHAIN_CHAT_BEDROCK_CONVERSE, LANGCHAIN_CHAT_OPENAI, - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, candidate_keys, probe_forwarded_keys, sample, @@ -301,7 +300,7 @@ class TestForwardsExactlyTheCrossSdkList: "openai", "langchain_openai", "ChatOpenAI", - LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + LANGCHAIN_CHAT_OPENAI, ), ( "anthropic", @@ -329,19 +328,12 @@ async def call(parameters: dict[str, Any]) -> object: assert await probe_forwarded_keys(candidates, call) == expected assert baseline == {"model": "configured-model"} - def test_chat_openai_has_no_prompt_cache_key_field(self) -> None: - """Why ``prompt_cache_key`` is left off: ``ChatOpenAI`` cannot take it. When it can, - this fails and the key goes on the list.""" - assert not LANGCHAIN_CHAT_OPENAI_UNSUPPORTED & _accepted_keys( - langchain_openai.ChatOpenAI - ) - @pytest.mark.parametrize( ("cls", "keys"), [ ( langchain_openai.ChatOpenAI, - LANGCHAIN_CHAT_OPENAI - LANGCHAIN_CHAT_OPENAI_UNSUPPORTED, + LANGCHAIN_CHAT_OPENAI, ), (langchain_anthropic.ChatAnthropic, LANGCHAIN_CHAT_ANTHROPIC), ], @@ -419,3 +411,11 @@ def test_max_tokens_wins_when_both_are_set(self) -> None: assert _build( "anthropic", {"max_tokens_to_sample": 300, "max_tokens": 200} ) == {"model": "configured-model", "max_tokens": 200} + + +def test_prompt_cache_key_is_dropped_for_chat_openai() -> None: + """Cut from the cross-SDK ChatOpenAI list: a config value never reaches the constructor.""" + assert _build("openai", {"prompt_cache_key": "cache-1", "temperature": 0.2}) == { + "model": "configured-model", + "temperature": 0.2, + } diff --git a/tests/forwarding_spec.py b/tests/forwarding_spec.py index fec927f3..f8f250eb 100644 --- a/tests/forwarding_spec.py +++ b/tests/forwarding_spec.py @@ -83,7 +83,6 @@ "max_tokens", "n", "presence_penalty", - "prompt_cache_key", "service_tier", "stop", "stop_sequences", @@ -94,10 +93,6 @@ } ) -#: Canonical keys ``ChatOpenAI`` (langchain-openai 1.3.3) has no field for, so the handler cannot -#: set them and leaves them out. -LANGCHAIN_CHAT_OPENAI_UNSUPPORTED = frozenset({"prompt_cache_key"}) - LANGCHAIN_CHAT_ANTHROPIC = frozenset( { "betas",