Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions packages/claude-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ to_claude_agents(
)
```

### Output limit and thinking

- `max_tokens` in the config's model parameters sets the output-token limit for each model request. The Agent SDK has no option for it, so the handler sets `CLAUDE_CODE_MAX_OUTPUT_TOKENS` in the environment of the Claude Code process. Without `max_tokens`, Claude Code uses its own default. A `CLAUDE_CODE_MAX_OUTPUT_TOKENS` in the `env` of a Claude Code settings file, including `~/.claude.json`, wins over `max_tokens`.
- Thinking is off unless the config sets `thinking` or `max_thinking_tokens`. Models that reject disabled thinking, such as `claude-fable-5`, still use their own default. Claude Code lowers a thinking budget to one less than `max_tokens`, so when a config sets both, keep `max_tokens` above the budget.

#### Migrating from a version that used Claude Code's thinking default

Earlier versions passed no thinking setting when the config had none, so Claude Code chose one: adaptive thinking on models that support it, and a 31,999-token budget on others such as `claude-haiku-4-5`. A run now thinks only when its config asks. To keep thinking, set it in the config's model parameters, for example `{"thinking": {"type": "adaptive"}}`.

### Convenience wrapper

```python
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@
#: 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.
#: The SDK offers no ``temperature``/``top_p``/``top_k``/``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. It has no ``max_tokens`` field
#: either, but ``max_tokens`` is not dropped: :func:`_options_parameters` sends it to the CLI as
#: :data:`_MAX_OUTPUT_TOKENS_ENV`.
_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS = frozenset(
{
"betas",
Expand Down Expand Up @@ -600,6 +602,16 @@ def _raise_if_run_failed(message: Any) -> None:
)


#: The variable the CLI reads its output-token limit from. ``ClaudeAgentOptions`` has no
#: output-token field, so the config's ``max_tokens`` reaches the CLI as this variable in its
#: environment, the only value this package writes there. Without it the CLI sends its own default
#: (32000 on ``claude-haiku-4-5``) whatever the config asks for.
_MAX_OUTPUT_TOKENS_ENV = "CLAUDE_CODE_MAX_OUTPUT_TOKENS"

#: The largest ``max_tokens`` kept, ``Number.MAX_SAFE_INTEGER`` in JS: the JS SDK drops a larger
#: value, so both SDKs send the same thing for the same config.
_MAX_TOKENS_LIMIT = 2**53 - 1

#: The ``thinking`` types ``claude_agent_sdk`` knows (``ThinkingConfig``). It reads
#: ``thinking["type"]`` when it builds the CLI command, so a ``thinking`` without one raises.
_THINKING_TYPES = frozenset({"adaptive", "enabled", "disabled"})
Expand Down Expand Up @@ -636,16 +648,36 @@ def _options_parameters(config: AiConfigRep) -> dict[str, Any]:
:data:`_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS`, with malformed object values dropped and
``thinking`` rebuilt by :func:`_rebuild_thinking`. The handler and the native graph both call
this, so they forward the same keys.

Two fields are set even though the config does not name them. ``thinking`` is
``{"type": "disabled"}`` unless the config sets ``thinking`` or ``max_thinking_tokens``,
because the CLI otherwise picks thinking itself (a 31,999-token budget on
``claude-haiku-4-5``). And a positive integer ``max_tokens`` up to
:data:`_MAX_TOKENS_LIMIT` becomes
``env={_MAX_OUTPUT_TOKENS_ENV: ...}``, which the SDK adds to the inherited ``os.environ``.
"""
parameters = model_parameters(config)
params = select_forwarded_parameters(
model_parameters(config),
parameters,
_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS,
mapping_keys=_CLAUDE_AGENT_OPTIONS_MAPPING_KEYS,
)
if "thinking" in params:
thinking = _rebuild_thinking(params.pop("thinking"))
if thinking is not None:
params["thinking"] = thinking
if "thinking" not in params and params.get("max_thinking_tokens") is None:
params["thinking"] = {"type": "disabled"}
max_tokens = parameters.get("max_tokens")
# JSON has one number type, so ``200.0`` is the same value the JS SDK reads as 200.
if isinstance(max_tokens, float) and max_tokens.is_integer():
max_tokens = int(max_tokens)
if (
isinstance(max_tokens, int)
and not isinstance(max_tokens, bool)
and 0 < max_tokens <= _MAX_TOKENS_LIMIT
):
params["env"] = {_MAX_OUTPUT_TOKENS_ENV: str(max_tokens)}
return params


Expand Down
156 changes: 149 additions & 7 deletions packages/claude-agents/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

import os
from collections.abc import AsyncIterator
from typing import Any, ClassVar
from unittest.mock import AsyncMock, MagicMock, patch
Expand Down Expand Up @@ -1664,7 +1665,7 @@ async def test_unset_when_no_parameters(
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``/
"""``ClaudeAgentOptions`` has no ``temperature``/``top_p``/``top_k``/
``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.
Expand All @@ -1677,7 +1678,6 @@ async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising(
"temperature": 0.2,
"top_p": 0.5,
"top_k": 10,
"max_tokens": 256,
"stop_sequences": ["STOP"],
"tool_choice": "auto",
"metadata": {"user_id": "u1"},
Expand All @@ -1691,7 +1691,6 @@ async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising(
"temperature",
"top_p",
"top_k",
"max_tokens",
"stop_sequences",
"tool_choice",
"metadata",
Expand Down Expand Up @@ -1732,6 +1731,148 @@ async def test_temperature_top_p_and_max_turns_run_without_type_error(
assert options.max_turns == 5


class TestOutputLimitAndThinkingDefault:
"""The cross-SDK Claude Agents rule: ``max_tokens`` reaches the CLI as
``CLAUDE_CODE_MAX_OUTPUT_TOKENS``, and thinking is off unless the config asks for it. The CLI
would otherwise send its own defaults (``max_tokens`` 32000 and a 31,999-token thinking budget
on ``claude-haiku-4-5``)."""

@staticmethod
async def _options(
parameters: dict[str, Any], path: str, monkeypatch: pytest.MonkeyPatch
) -> Any:
captured: dict[str, Any] = {}

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

monkeypatch.setattr(handler_mod, "query", _query)
config = {
**BASE_CONFIG,
"model": {**BASE_CONFIG["model"], "parameters": parameters},
}
h = create_claude_agents_handler()
if path == "invoke":
await h(config, "q")
else:
await _collect(await h.stream(config, "q"))
return captured["options"]

@pytest.fixture(params=["invoke", "stream"])
def path(self, request: pytest.FixtureRequest) -> str:
return request.param

async def test_max_tokens_sets_the_cli_output_limit_and_thinking_is_disabled(
self, path: str, monkeypatch: pytest.MonkeyPatch
) -> None:
options = await self._options({"max_tokens": 200}, path, monkeypatch)
# The SDK adds ``env`` to the inherited ``os.environ``, so only the one variable is set.
assert options.env == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "200"}
assert options.thinking == {"type": "disabled"}
assert "--thinking" in (command := build_cli_command(options))
assert command[command.index("--thinking") + 1] == "disabled"

async def test_an_integral_float_max_tokens_is_read_as_an_integer(
self, path: str, monkeypatch: pytest.MonkeyPatch
) -> None:
options = await self._options({"max_tokens": 200.0}, path, monkeypatch)
assert options.env == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "200"}

async def test_no_env_without_max_tokens(
self, path: str, monkeypatch: pytest.MonkeyPatch
) -> None:
options = await self._options({"max_turns": 2}, path, monkeypatch)
assert options.env == {}
assert options.thinking == {"type": "disabled"}

@pytest.mark.parametrize(
"max_tokens",
[0, -5, 1.5, 2**53, float("inf"), "200", True, None, {"value": 200}],
)
async def test_a_max_tokens_that_is_not_a_positive_integer_is_dropped(
self, path: str, max_tokens: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
options = await self._options({"max_tokens": max_tokens}, path, monkeypatch)
assert options.env == {}

async def test_a_configured_thinking_replaces_the_default(
self, path: str, monkeypatch: pytest.MonkeyPatch
) -> None:
options = await self._options(
{
"max_tokens": 4096,
"thinking": {"type": "enabled", "budget_tokens": 2048},
},
path,
monkeypatch,
)
assert options.thinking == {"type": "enabled", "budget_tokens": 2048}
assert options.env == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "4096"}

async def test_max_thinking_tokens_alone_leaves_thinking_unset(
self, path: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``thinking`` takes precedence over ``max_thinking_tokens`` in the SDK, so the default
must not be added or the config's budget would be ignored."""
options = await self._options({"max_thinking_tokens": 2048}, path, monkeypatch)
assert options.max_thinking_tokens == 2048
assert options.thinking is None

async def test_thinking_and_max_thinking_tokens_together_pass_as_given(
self, path: str, monkeypatch: pytest.MonkeyPatch
) -> None:
options = await self._options(
{
"max_thinking_tokens": 4096,
"thinking": {"type": "enabled", "budget_tokens": 2048},
},
path,
monkeypatch,
)
assert options.max_thinking_tokens == 4096
assert options.thinking == {"type": "enabled", "budget_tokens": 2048}

async def test_effort_alone_does_not_turn_thinking_on(
self, path: str, monkeypatch: pytest.MonkeyPatch
) -> None:
options = await self._options({"effort": "low"}, path, monkeypatch)
assert options.effort == "low"
assert options.thinking == {"type": "disabled"}

async def test_the_cli_process_keeps_the_inherited_environment(
self, path: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``env`` holds only the one variable because the SDK adds it to ``os.environ``. Start
the SDK's own transport with the options and read the environment it gives the CLI, so a
change to that merge fails here."""
import dataclasses

import claude_agent_sdk._internal.transport.subprocess_cli as subprocess_cli
from claude_agent_sdk import CLIConnectionError

options = await self._options({"max_tokens": 200}, path, monkeypatch)
monkeypatch.setenv("LD_TEST_INHERITED", "kept")
monkeypatch.setenv("CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK", "1")
spawned: dict[str, Any] = {}

async def _open_process(cmd: Any, **kwargs: Any) -> Any:
spawned["env"] = kwargs["env"]
raise RuntimeError("stop before spawning")

monkeypatch.setattr(subprocess_cli.anyio, "open_process", _open_process)
transport = subprocess_cli.SubprocessCLITransport(
prompt="q", options=dataclasses.replace(options, cli_path="/bin/echo")
)
with pytest.raises(CLIConnectionError):
await transport.connect()
assert spawned["env"]["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] == "200"
assert spawned["env"]["LD_TEST_INHERITED"] == "kept"
assert spawned["env"]["PATH"] == os.environ["PATH"]


class TestFinishReasonMapping:
async def test_tool_use_maps_to_tool_calls(
self, monkeypatch: pytest.MonkeyPatch
Expand Down Expand Up @@ -2821,12 +2962,13 @@ def test_a_known_type_keeps_only_the_sub_keys_the_sdk_reads(

@pytest.mark.parametrize("value", [{"budget_tokens": 1024}, {"type": "enabled"}])
def test_the_shapes_the_sdk_cannot_read_are_dropped(self, value: Any) -> None:
"""Both raise ``KeyError`` in the SDK's command builder when passed through."""
"""Both raise ``KeyError`` in the SDK's command builder when passed through. A dropped
thinking counts as not set, so the handler's disabled default applies."""
from claude_agent_sdk import ClaudeAgentOptions

with pytest.raises(KeyError):
build_cli_command(ClaudeAgentOptions(thinking=value))
assert self._thinking(value) is None
assert self._thinking(value) == {"type": "disabled"}

@pytest.mark.parametrize(
"value",
Expand All @@ -2838,7 +2980,7 @@ def test_the_shapes_the_sdk_cannot_read_are_dropped(self, value: Any) -> None:
],
)
def test_an_unknown_type_or_a_malformed_budget_is_dropped(self, value: Any) -> None:
assert self._thinking(value) is None
assert self._thinking(value) == {"type": "disabled"}


class TestMalformedObjectValuesAreDropped:
Expand All @@ -2858,7 +3000,7 @@ def test_a_thinking_that_is_not_an_object_and_output_format_are_dropped(
}
}
)
assert params == {"max_turns": 3}
assert params == {"max_turns": 3, "thinking": {"type": "disabled"}}


async def test_a_builtin_tool_result_is_not_marked_failed(
Expand Down
67 changes: 67 additions & 0 deletions packages/claude-agents/tests/test_native_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,73 @@ async def test_node_options_take_run_settings_and_nothing_never_forwarded(
assert opts["max_turns"] == 7
assert not find_leaks(opts)

@pytest.mark.asyncio
async def test_node_max_tokens_sets_the_cli_output_limit_and_thinking_is_disabled(
self,
) -> None:
"""A node's ``max_tokens`` reaches its CLI as
``CLAUDE_CODE_MAX_OUTPUT_TOKENS``, and thinking is off unless the node's config asks."""

async def node_options(parameters: dict[str, Any]) -> dict[str, Any]:
mock_sdk = _make_sdk_mock("done")
nodes = {
"root": {
"key": "root",
"config": {
"model": {"name": "claude-3", "parameters": parameters},
"instructions": "be helpful",
},
"meta": {"variationKey": "v1", "version": 1},
"edges": [],
"is_terminal": True,
}
}
captured: list[dict[str, Any]] = []
mock_sdk.ClaudeAgentOptions = MagicMock(
side_effect=lambda **kw: (captured.append(kw), kw)[1]
)
with patch(
"importlib.import_module",
side_effect=lambda n: (
mock_sdk if n == "claude_agent_sdk" else __import__(n)
),
):
await to_claude_agents(
_make_def_promise(_make_graph_def(nodes=nodes))
).invoke("hi")
assert len(captured) == 1
return captured[0]

limited = await node_options({"max_tokens": 200})
assert limited["env"] == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "200"}
assert limited["thinking"] == {"type": "disabled"}
# Built by the real SDK, so the node's CLI is started with thinking off.
command = _build_cli_command(limited)
assert command[command.index("--thinking") + 1] == "disabled"

invalid = await node_options({"max_tokens": 1.5})
Comment thread
XieX marked this conversation as resolved.
assert "env" not in invalid
assert invalid["thinking"] == {"type": "disabled"}

malformed = await node_options({"thinking": {"type": "enabled"}})
assert malformed["thinking"] == {"type": "disabled"}

effort_only = await node_options({"effort": "low"})
assert effort_only["effort"] == "low"
assert effort_only["thinking"] == {"type": "disabled"}

budget_only = await node_options({"max_thinking_tokens": 2048})
assert budget_only["max_thinking_tokens"] == 2048
assert "thinking" not in budget_only
command = _build_cli_command(budget_only)
assert command[command.index("--max-thinking-tokens") + 1] == "2048"

thinking = await node_options(
{"thinking": {"type": "enabled", "budget_tokens": 2048}}
)
assert "env" not in thinking
assert thinking["thinking"] == {"type": "enabled", "budget_tokens": 2048}


def _build_cli_command(options_kwargs: dict[str, Any]) -> list[str]:
"""The CLI command the real ``claude_agent_sdk`` would launch for one node's options, built by
Expand Down
1 change: 1 addition & 0 deletions tests/forwarding_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"fallback_model",
"max_budget_usd",
"max_thinking_tokens",
"max_tokens",
"max_turns",
"thinking",
}
Expand Down
Loading