Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9ad50a5
fix(client): forward model.parameters to every provider handler
apucacao Sep 24, 2026
beabd79
fix(client): filter model.parameters to what each provider actually a…
apucacao Sep 24, 2026
2e91885
refactor(client): replace runtime SDK introspection with explicit for…
apucacao Sep 24, 2026
d51199d
fix(langchain): never forward model_name or model_id from model.param…
apucacao Sep 24, 2026
8144a3d
Merge remote-tracking branch 'origin/main' into alexis/forward-model-…
apucacao Oct 2, 2026
c25d33b
refactor(client): stop exporting model_parameters and select_forwarde…
apucacao Oct 5, 2026
828279f
fix(claude-agents): forward only model and run settings from model.pa…
apucacao Oct 5, 2026
38d463a
fix(langchain): forward only model request settings to chat models
apucacao Oct 5, 2026
5a43f72
fix(claude-messages): forward the same keys on invoke and stream
apucacao Oct 5, 2026
5314cb5
fix(openai-messages): forward the same keys on invoke and stream
apucacao Oct 5, 2026
d681399
refactor(openai-agents): drop the dead max_turns pop
apucacao Oct 5, 2026
4a233d7
test: check every handler's forwarded lists against the never-forward…
apucacao Oct 5, 2026
fba3d67
fix: stop forwarding container state and request attribution
apucacao Oct 5, 2026
1a1539e
test(claude-agents): replace the extra_body check with the never-forw…
apucacao Oct 5, 2026
175050b
Merge remote-tracking branch 'origin/main' into alexis/forward-model-…
apucacao Oct 5, 2026
372c2ae
test: require a forwarded-keys entry for every discovered handler module
apucacao Oct 7, 2026
9055c2a
feat(client): drop malformed object values when selecting forwarded p…
apucacao Oct 7, 2026
a2a7d73
test: add the cross-SDK forwarded-key lists and a per-key probe
apucacao Oct 7, 2026
956315c
fix(claude-messages): forward exactly the cross-SDK Claude Messages list
apucacao Oct 7, 2026
fba0880
fix(openai-messages): forward exactly the cross-SDK OpenAI Messages list
apucacao Oct 7, 2026
4b03acd
fix(openai-agents): forward exactly the cross-SDK OpenAI Agents list
apucacao Oct 7, 2026
f9b1bf5
fix(claude-agents): drop thinking and output_format values that are n…
apucacao Oct 7, 2026
1c60bdf
fix(langchain): forward exactly the cross-SDK lists for each chat model
apucacao Oct 7, 2026
4e92432
test: add the newly excluded categories to the never-forwarded bag
apucacao Oct 7, 2026
8c1f9f7
fix(openai-messages): stop forwarding max_tool_calls
apucacao Oct 7, 2026
7cfb3a7
fix(langchain): rename max_tokens_to_sample to max_tokens for ChatAnt…
apucacao Oct 7, 2026
52bf2d2
fix(openai-agents): rebuild reasoning as Reasoning and let text.verbo…
apucacao Oct 7, 2026
c76acd5
fix(langchain): cut prompt_cache_key from the ChatOpenAI list
apucacao Oct 7, 2026
db839fe
Merge main into alexis/forward-model-parameters
apucacao Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
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 ._version import PACKAGE_NAME, __version__
from .spans import (
Expand All @@ -68,6 +70,94 @@
tool_display_name,
)

#: Every field ``ClaudeAgentOptions`` declares, classified by hand into exactly one of: forwarded
#: (below), handler-owned (``model``, ``allowed_tools``, ``mcp_servers``, ``hooks``, ``tools``,
#: ``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.
#:
#: This is the cross-SDK list for Claude Agents (TESTING.md section 1.12), shared with the native
#: graph through :func:`_options_parameters`.
#:
#: The SDK offers no ``temperature``/``top_p``/``top_k``/``max_tokens``/``stop_sequences``/
#: ``tool_choice``/``metadata``, all of which the LaunchDarkly UI's model parameters panel offers
#: for other providers; they are dropped like any other key not listed here.
_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS = frozenset(
{
"betas",
"effort",
"fallback_model",
"max_budget_usd",
"max_thinking_tokens",
"max_turns",
"output_format",
"thinking",
}
)

#: Forwarded keys whose value must be an object. A config value of any other type is malformed
#: and dropped rather than passed to ``ClaudeAgentOptions``.
_CLAUDE_AGENT_OPTIONS_MAPPING_KEYS = frozenset({"output_format", "thinking"})

#: Accepted by ``ClaudeAgentOptions`` but never forwarded, and why:
#: * ``cli_path``, ``env``, ``cwd``, ``add_dirs``, ``settings``, ``setting_sources``, ``plugins``,
#: ``skills``, ``sandbox``, ``user``, ``extra_args``: which binary runs, with what environment,
#: 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",
"can_use_tool",
"cli_path",
Comment thread
jeffdupont marked this conversation as resolved.
"continue_conversation",
"cwd",
"debug_stderr",
"disallowed_tools",
"enable_file_checkpointing",
"env",
"extra_args",
"fork_session",
"include_hook_events",
"include_partial_messages",
"load_timeout_ms",
"max_buffer_size",
"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",
"user",
}
)
Comment thread
cursor[bot] marked this conversation as resolved.

# ---------------------------------------------------------------------------
# Tool wiring
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -471,6 +561,18 @@ def _result_error(subtype: str, errors: list[str] | None) -> str:
return f"Claude agent run ended with {subtype}{detail}"


def _options_parameters(config: AiConfigRep) -> dict[str, Any]:
"""The config's ``model.parameters`` that become ``ClaudeAgentOptions`` fields: only
:data:`_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS`, with malformed object values dropped. The handler
and the native graph both call this, so they forward the same keys.
"""
return select_forwarded_parameters(
model_parameters(config),
_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS,
mapping_keys=_CLAUDE_AGENT_OPTIONS_MAPPING_KEYS,
)


def _build_query_options(
config: AiConfigRep,
system_prompt: str | None,
Expand All @@ -481,7 +583,9 @@ def _build_query_options(
**extra: Any,
) -> ClaudeAgentOptions:
all_allowed = [*mcp_allowed_tools, *native_tool_names]
params = _options_parameters(config)
kwargs: dict[str, Any] = {
**params,
Comment thread
cursor[bot] marked this conversation as resolved.
"model": config["model"]["name"],
"allowed_tools": all_allowed if all_allowed else [],
"mcp_servers": {TOOL_MCP_NAME: tool_mcp} if tool_mcp else {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

from launchdarkly_ai_claude_agents.handler import (
_build_hooks,
_options_parameters,
build_prompt,
build_query_prompt,
build_tool_mcp,
Expand Down Expand Up @@ -152,7 +153,10 @@ async def _run_query(

hooks = _build_hooks(native_tool_map)

params = _options_parameters(node.config)

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
Expand Down
212 changes: 212 additions & 0 deletions packages/claude-agents/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import pytest
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
StreamEvent,
SystemMessage,
Expand All @@ -43,6 +44,12 @@
partition_tools,
)
from launchdarkly_ai_server import ConversationIdSpanProcessor, conversation_id
from tests.forwarding_spec import (
CLAUDE_AGENTS,
candidate_keys,
probe_forwarded_keys,
)
from tests.never_forwarded import NEVER_FORWARDED_BAG, find_leaks

# ---------------------------------------------------------------------------
# A real tracer provider, reset between tests
Expand Down Expand Up @@ -1442,6 +1449,125 @@ 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

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_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": {**NEVER_FORWARDED_BAG, "max_turns": 2},
},
}
options = await self._run_and_capture_options(config, monkeypatch)
assert options.max_turns == 2
assert not find_leaks(options)

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(
self, monkeypatch: pytest.MonkeyPatch
Expand Down Expand Up @@ -2229,3 +2355,89 @@ async def _query(**kwargs: Any) -> AsyncIterator[Any]:

tool = named("execute_tool ")[0]
assert tool.end_time is not None


#: ``ClaudeAgentOptions`` fields the handler fills with fresh objects on every call (hook
#: closures, the tool MCP server), so they differ between calls whatever the config says. Every
#: config-settable field is still compared.
_PER_CALL_FIELDS = frozenset({"hooks", "mcp_servers"})


def _comparable(options: Any) -> dict[str, Any]:
import dataclasses

return {
f.name: getattr(options, f.name)
for f in dataclasses.fields(options)
if f.name not in _PER_CALL_FIELDS
}


class TestForwardsExactlyTheCrossSdkList:
"""Probes ``invoke`` and ``stream`` one key at a time: the keys that change the
``ClaudeAgentOptions`` handed to ``query`` are exactly the cross-SDK Claude Agents list, on
both paths."""

@staticmethod
def _candidates() -> frozenset[str]:
import dataclasses

return candidate_keys(
(f.name for f in dataclasses.fields(ClaudeAgentOptions)), CLAUDE_AGENTS
)

@staticmethod
def _config(parameters: dict[str, Any]) -> dict[str, Any]:
return {
**BASE_CONFIG,
"model": {**BASE_CONFIG["model"], "parameters": parameters},
}

def _patch_query(self, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
captured: dict[str, Any] = {}

async def _query(**kwargs: Any) -> AsyncIterator[Any]:
captured["options"] = kwargs["options"]
yield assistant_message()
yield result_message()

monkeypatch.setattr(handler_mod, "query", _query)
return captured

async def test_invoke(self, monkeypatch: pytest.MonkeyPatch) -> None:
captured = self._patch_query(monkeypatch)
h = create_claude_agents_handler()

async def call(parameters: dict[str, Any]) -> object:
await h(self._config(parameters), "q")
return _comparable(captured["options"])

assert await probe_forwarded_keys(self._candidates(), call) == CLAUDE_AGENTS

async def test_stream(self, monkeypatch: pytest.MonkeyPatch) -> None:
captured = self._patch_query(monkeypatch)
h = create_claude_agents_handler()

async def call(parameters: dict[str, Any]) -> object:
await _collect(await h.stream(self._config(parameters), "q"))
return _comparable(captured["options"])

assert await probe_forwarded_keys(self._candidates(), call) == CLAUDE_AGENTS


class TestMalformedObjectValuesAreDropped:
def test_thinking_and_output_format_that_are_not_objects_are_dropped(self) -> None:
from launchdarkly_ai_claude_agents.handler import _options_parameters

params = _options_parameters(
{
"model": {
"parameters": {
"thinking": "adaptive",
"output_format": "json",
"max_turns": 3,
}
}
}
)
assert params == {"max_turns": 3}
Loading
Loading