From 9ad50a5871537a455fc650ff2b06a14f45dfce85 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Thu, 24 Sep 2026 11:02:59 -0400 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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",