From 19dc66a48cebd3ca511034226141880b027e72d7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:01:47 -0700 Subject: [PATCH 1/4] fix(anthropic): add the per-turn-control beta when a message carries output_config --- litellm/anthropic_beta_headers_config.json | 6 + .../messages/transformation.py | 31 ++--- .../anthropic/messages_transformation.py | 1 + .../messages_transformation.py | 1 + .../llms/deepseek/messages/transformation.py | 1 + .../github_copilot/messages/transformation.py | 2 +- .../openai_like/messages/transformation.py | 1 + litellm/types/llms/anthropic.py | 1 + ...est_anthropic_messages_per_turn_control.py | 126 ++++++++++++++++++ 9 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index 3f6817f6e35..8dc9204af8d 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -22,6 +22,7 @@ "mcp-servers-2025-12-04": null, "oauth-2025-04-20": "oauth-2025-04-20", "output-128k-2025-02-19": "output-128k-2025-02-19", + "per-turn-control-2026-07-01": "per-turn-control-2026-07-01", "prompt-caching-scope-2026-01-05": "prompt-caching-scope-2026-01-05", "skills-2025-10-02": "skills-2025-10-02", "structured-outputs-2025-11-13": "structured-outputs-2025-11-13", @@ -52,6 +53,7 @@ "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": null, "structured-output-2024-03-01": null, + "per-turn-control-2026-07-01": null, "prompt-caching-scope-2026-01-05": "prompt-caching-scope-2026-01-05", "skills-2025-10-02": "skills-2025-10-02", "structured-outputs-2025-11-13": "structured-outputs-2025-11-13", @@ -82,6 +84,7 @@ "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": null, "structured-output-2024-03-01": null, + "per-turn-control-2026-07-01": null, "prompt-caching-scope-2026-01-05": null, "skills-2025-10-02": null, "structured-outputs-2025-11-13": "structured-outputs-2025-11-13", @@ -113,6 +116,7 @@ "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": null, "structured-output-2024-03-01": null, + "per-turn-control-2026-07-01": null, "prompt-caching-scope-2026-01-05": null, "skills-2025-10-02": null, "structured-outputs-2025-11-13": null, @@ -144,6 +148,7 @@ "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": null, "structured-output-2024-03-01": null, + "per-turn-control-2026-07-01": null, "prompt-caching-scope-2026-01-05": null, "skills-2025-10-02": null, "structured-outputs-2025-11-13": null, @@ -176,6 +181,7 @@ "mcp-servers-2025-12-04": null, "oauth-2025-04-20": "oauth-2025-04-20", "output-128k-2025-02-19": "output-128k-2025-02-19", + "per-turn-control-2026-07-01": null, "prompt-caching-scope-2026-01-05": "prompt-caching-scope-2026-01-05", "skills-2025-10-02": "skills-2025-10-02", "structured-outputs-2025-11-13": "structured-outputs-2025-11-13", diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 9f9346fad4d..67cda1984cb 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -42,6 +42,10 @@ DROP_UNFITTING_REASONING_EFFORT_WARNING: Final = ( ) +def _messages_carry_output_config(messages: Sequence[object]) -> bool: + return any(isinstance(message, Mapping) and "output_config" in message for message in messages) + + class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): @property def custom_llm_provider(self) -> str | None: @@ -331,6 +335,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): headers = self._update_headers_with_anthropic_beta( headers=headers, optional_params=optional_params, + messages=messages, ) return headers, api_base @@ -664,6 +669,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): headers: dict, optional_params: dict, custom_llm_provider: str = "anthropic", + messages: Sequence[object] = (), ) -> dict: """ Auto-inject anthropic-beta headers based on features used. @@ -673,11 +679,13 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): - tool_search: adds provider-specific tool search header - output_format: adds 'structured-outputs-2025-11-13' - speed: adds 'fast-mode-2026-02-01' + - a message carrying output_config: adds 'per-turn-control-2026-07-01' Args: headers: Request headers dict optional_params: Optional parameters including tools, context_management, output_format, speed custom_llm_provider: Provider name for looking up correct tool search header + messages: Request messages, scanned for per-message output_config """ beta_values: Final[set] = set() @@ -722,22 +730,15 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if optional_params.get("speed") == "fast": beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.FAST_MODE_2026_02_01.value) - # Check for advisor tool - tools = optional_params.get("tools") - if tools: - for tool in tools: - if isinstance(tool, dict) and tool.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE: - beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value) - break + if _messages_carry_output_config(messages): + beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value) - # Check for tool search tools - tools = optional_params.get("tools") - if tools: - anthropic_model_info: Final = AnthropicModelInfo() - if anthropic_model_info.is_tool_search_used(tools): - # Use provider-specific tool search header - tool_search_header: Final = get_tool_search_beta_header(custom_llm_provider) - beta_values.add(tool_search_header) + tools: Final = optional_params.get("tools") + if any(isinstance(tool, dict) and tool.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE for tool in tools or ()): + beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.ADVISOR_TOOL_2026_03_01.value) + + if AnthropicModelInfo().is_tool_search_used(tools): + beta_values.add(get_tool_search_beta_header(custom_llm_provider)) if beta_values: headers["anthropic-beta"] = ",".join(sorted(beta_values)) diff --git a/litellm/llms/azure_ai/anthropic/messages_transformation.py b/litellm/llms/azure_ai/anthropic/messages_transformation.py index 862ff584cf3..36164106a5a 100644 --- a/litellm/llms/azure_ai/anthropic/messages_transformation.py +++ b/litellm/llms/azure_ai/anthropic/messages_transformation.py @@ -68,6 +68,7 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig): headers = self._update_headers_with_anthropic_beta( headers=headers, optional_params=optional_params, + messages=messages, ) return headers, api_base diff --git a/litellm/llms/bedrock/claude_platform/messages_transformation.py b/litellm/llms/bedrock/claude_platform/messages_transformation.py index 55c9559ab07..3add682ef6d 100644 --- a/litellm/llms/bedrock/claude_platform/messages_transformation.py +++ b/litellm/llms/bedrock/claude_platform/messages_transformation.py @@ -46,6 +46,7 @@ class BedrockClaudePlatformMessagesConfig(BedrockClaudePlatformMixin, AnthropicM headers = self._update_headers_with_anthropic_beta( headers=headers, optional_params=optional_params, + messages=messages, ) return headers, api_base diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py index c6c527192cc..8dd720c464a 100644 --- a/litellm/llms/deepseek/messages/transformation.py +++ b/litellm/llms/deepseek/messages/transformation.py @@ -66,6 +66,7 @@ class DeepSeekAnthropicMessagesConfig(AnthropicMessagesConfig): headers=headers, optional_params=optional_params, custom_llm_provider=self.custom_llm_provider or "deepseek", + messages=messages, ) return headers, api_base diff --git a/litellm/llms/github_copilot/messages/transformation.py b/litellm/llms/github_copilot/messages/transformation.py index cec7efff38e..142df6a5a0c 100644 --- a/litellm/llms/github_copilot/messages/transformation.py +++ b/litellm/llms/github_copilot/messages/transformation.py @@ -92,7 +92,7 @@ class GithubCopilotAnthropicMessagesConfig(AnthropicMessagesConfig): headers["anthropic-version"] = "2023-06-01" headers = self._update_headers_with_anthropic_beta( - headers, optional_params, custom_llm_provider="github_copilot" + headers, optional_params, custom_llm_provider="github_copilot", messages=messages ) return headers, dynamic_api_base diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index ac99617521c..bae190c88c0 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -56,6 +56,7 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig): merged: Final = self._update_headers_with_anthropic_beta( headers=normalized, optional_params=optional_params, + messages=messages, ) return merged, api_base diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 365d59a179b..d56ada07ed5 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -746,6 +746,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum): ADVANCED_TOOL_USE_2025_11_20 = "advanced-tool-use-2025-11-20" FAST_MODE_2026_02_01 = "fast-mode-2026-02-01" ADVISOR_TOOL_2026_03_01 = "advisor-tool-2026-03-01" + PER_TURN_CONTROL_2026_07_01 = "per-turn-control-2026-07-01" # Tool search beta header constant (for Anthropic direct API and Microsoft Foundry) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py new file mode 100644 index 00000000000..7ed3526c4b6 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py @@ -0,0 +1,126 @@ +import pytest + +from litellm import anthropic_beta_headers_manager +from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) +from litellm.llms.openai_like.json_loader import SimpleProviderConfig +from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, +) + +PER_TURN_CONTROL = "per-turn-control-2026-07-01" + +CLAUDE_CODE_BETAS = ( + "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27," + "per-turn-control-2026-07-01,effort-2025-11-24" +) + + +def _claude_code_turn(system_output_config): + """Claude Code changes effort mid-conversation by appending a ``role: system`` + message that carries ``output_config``; the rest of the body is unchanged.""" + return [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + {"role": "system", "content": [{"type": "text", "text": "# Environment"}], "output_config": system_output_config}, + ] + + +def _betas(headers): + return {beta for beta in headers.get("anthropic-beta", "").split(",") if beta} + + +def _validate(messages, headers=None, optional_params=None): + validated, _ = AnthropicMessagesConfig().validate_anthropic_messages_environment( + headers=dict(headers or {}), + model="claude-fable-5-1", + messages=messages, + optional_params=dict(optional_params or {"max_tokens": 64000, "output_config": {"effort": "high"}}), + litellm_params={}, + api_key="sk-ant-test", + ) + return validated + + +@pytest.fixture(autouse=True) +def bundled_beta_allowlist(monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True") + monkeypatch.setattr(anthropic_beta_headers_manager, "_BETA_HEADERS_CONFIG", None) + yield + monkeypatch.setattr(anthropic_beta_headers_manager, "_BETA_HEADERS_CONFIG", None) + + +def test_per_message_output_config_adds_per_turn_control_beta(): + """Anthropic rejects ``messages.N.output_config`` with a 400 unless the request + opts into ``per-turn-control-2026-07-01``, so a body carrying it must get the beta + even from a client whose ``anthropic-beta`` header never reached the proxy.""" + headers = _validate(_claude_code_turn({"effort": "high"})) + + assert PER_TURN_CONTROL in _betas(headers) + + +def test_top_level_output_config_alone_does_not_add_per_turn_control_beta(): + """Effort set once at the top level is plain GA request shape; only a message-level + ``output_config`` needs the per-turn beta.""" + headers = _validate([{"role": "user", "content": "Hello"}]) + + assert PER_TURN_CONTROL not in _betas(headers) + + +def test_string_messages_are_skipped_when_scanning_for_output_config(): + headers = _validate(["not a message dict", {"role": "user", "content": "Hello"}]) + + assert PER_TURN_CONTROL not in _betas(headers) + + +def test_forwarded_client_betas_survive_alongside_the_added_one(): + """With ``forward_client_headers_to_llm_api`` on, Claude Code's own beta list + arrives on the request; it is merged with the auto-added set, not replaced.""" + headers = _validate(_claude_code_turn({"effort": "low"}), headers={"anthropic-beta": CLAUDE_CODE_BETAS}) + + assert _betas(headers) >= set(CLAUDE_CODE_BETAS.split(",")) + assert PER_TURN_CONTROL in _betas(headers) + + +def test_added_per_turn_control_beta_survives_the_anthropic_allowlist(): + """The proxy filters ``anthropic-beta`` against the bundled allowlist right after + the headers are built. A name missing from it is dropped silently, which would turn + the auto-added beta back into the original 400, so the allowlist must carry it.""" + headers = _validate(_claude_code_turn({"effort": "high"})) + + filtered = update_headers_with_filtered_beta(headers=headers, provider="anthropic") + + assert PER_TURN_CONTROL in _betas(filtered) + + +@pytest.mark.parametrize("provider", ["bedrock", "bedrock_converse", "vertex_ai", "azure_ai", "databricks"]) +def test_per_turn_control_beta_is_dropped_for_providers_without_it(provider): + filtered = update_headers_with_filtered_beta(headers={"anthropic-beta": PER_TURN_CONTROL}, provider=provider) + + assert "anthropic-beta" not in filtered + + +def test_json_provider_passthrough_adds_per_turn_control_beta(): + """The generic Anthropic-compatible provider path builds its headers separately from + the Anthropic config and must scan the messages the same way.""" + config = JSONProviderAnthropicMessagesConfig( + SimpleProviderConfig( + "anthropic_like", + { + "base_url": "https://example.invalid", + "api_key_env": "ANTHROPIC_LIKE_API_KEY", + "supported_endpoints": ["/v1/messages"], + }, + ) + ) + headers, _ = config.validate_anthropic_messages_environment( + headers={}, + model="claude-fable-5-1", + messages=_claude_code_turn({"effort": "medium"}), + optional_params={"max_tokens": 1024}, + litellm_params={}, + api_key="test", + ) + + assert PER_TURN_CONTROL in _betas(headers) From c0fd8f6012f9ff2a8fe6681770439d54ec915bf8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:03:10 -0700 Subject: [PATCH 2/4] fix(anthropic): merge a case-variant Anthropic-Beta client header instead of clobbering it --- .../messages/transformation.py | 23 +++++++++++-------- ...est_anthropic_messages_per_turn_control.py | 12 ++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 67cda1984cb..27cdac34116 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -689,16 +689,20 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): """ beta_values: Final[set] = set() - # Get existing beta headers if any - existing_beta: Final = headers.get("anthropic-beta") - if existing_beta: - beta_values.update(b.strip() for b in existing_beta.split(",")) + existing_beta: Final = tuple( + piece.strip() + for key, value in headers.items() + if key.lower() == "anthropic-beta" + for piece in value.split(",") + if piece.strip() + ) + beta_values.update(existing_beta) # Check for context management context_management_param: Final = optional_params.get("context_management") if context_management_param is not None: # Check edits array for compact_20260112 type - edits: Final = context_management_param.get("edits", []) + edits: Final = context_management_param.get("edits", ()) has_compact = False has_other = False @@ -740,7 +744,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if AnthropicModelInfo().is_tool_search_used(tools): beta_values.add(get_tool_search_beta_header(custom_llm_provider)) - if beta_values: - headers["anthropic-beta"] = ",".join(sorted(beta_values)) - - return headers + if not beta_values: + return headers + merged: Final = {key: value for key, value in headers.items() if key.lower() != "anthropic-beta"} + merged["anthropic-beta"] = ",".join(sorted(beta_values)) + return merged diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py index 7ed3526c4b6..d444258d02f 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py @@ -83,6 +83,18 @@ def test_forwarded_client_betas_survive_alongside_the_added_one(): assert PER_TURN_CONTROL in _betas(headers) +def test_case_variant_client_beta_header_is_merged(): + """A client or config can spell the header ``Anthropic-Beta``; the proxy forwards it as + is, so the merge must read it whatever the casing and write one canonical header instead + of a lowercase one that clobbers it.""" + headers = _validate( + _claude_code_turn({"effort": "low"}), headers={"Anthropic-Beta": "interleaved-thinking-2025-05-14"} + ) + + assert [key for key in headers if key.lower() == "anthropic-beta"] == ["anthropic-beta"] + assert _betas(headers) == {"interleaved-thinking-2025-05-14", PER_TURN_CONTROL} + + def test_added_per_turn_control_beta_survives_the_anthropic_allowlist(): """The proxy filters ``anthropic-beta`` against the bundled allowlist right after the headers are built. A name missing from it is dropped silently, which would turn From 9bba2df58fb806c37d6ded3ed8ff6a65097e344a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:19:50 -0700 Subject: [PATCH 3/4] chore(ui): regenerate dashboard API types after merging main --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 51e7112a4f3..5446e944aa0 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -35541,7 +35541,7 @@ export interface components { default_model?: string | null; /** * Deployment Affinity - * @description When True and a session_id is resolvable on the request, pin the deployment chosen inside each routed model group and reuse it whenever the session returns to that group, without pinning which group the session routes to. Independent of session_affinity, which pins the model group instead (and always carries this deployment pin with it): with session_affinity off, every turn is still classified on its own merits while a session that escalates to a stronger tier and comes back still lands on the deployment it used before, which is what keeps a provider prompt cache warm. Pins are held per model group, so switching tiers does not disturb the pin left behind in the previous group. On by default because re-shuffling a conversation across deployments of the same model discards that cache for no benefit; set False to keep every turn load-balanced across the group, which is what a deployment set with tight per-deployment rate limits wants. Inert when no session_id is resolvable, since there is nothing to key a pin on, and suppressed when plugins are configured, for the same reason session_affinity is. + * @description When True and a client session_id is resolvable, reuse the session's chosen model for each classified tier and its deployment within each model group. With session_affinity off, every turn is still classified: moving to another tier leaves the previous tier's model pin intact for a later return. Pins yield to current candidate, context, modality, and availability constraints. Adaptive selection chooses the initial model from its eligible pool, then reuses that choice per tier. This reduces avoidable provider prompt-cache misses; it does not guarantee cache hits. Set False to select models and load-balance deployments on every turn, unless session_affinity or user_turn classification requires a pin. Inert without a client session_id and suppressed when plugins are configured. * @default true */ deployment_affinity: boolean; @@ -35685,7 +35685,7 @@ export interface components { session_affinity: boolean; /** * Session Affinity Ttl Seconds - * @description TTL for the session affinity pin; refreshed on every cache hit. Bounds both the session_affinity model pin and the deployment_affinity deployment pin, so it measures idle time for the session's routing decisions rather than total session length + * @description TTL for the session affinity pin; refreshed on every cache hit. Bounds both the session_affinity model pin and the deployment_affinity per-tier model and deployment pins, so it measures idle time for the session's routing decisions rather than total session length * @default 3600 */ session_affinity_ttl_seconds: number; From f49017233806ba248424521ab583497cca387719 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:39:01 -0700 Subject: [PATCH 4/4] test(anthropic): drop docstrings and wrap a long line in the per-turn-control tests --- ...est_anthropic_messages_per_turn_control.py | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py index d444258d02f..e80223ca01d 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_per_turn_control.py @@ -19,11 +19,13 @@ CLAUDE_CODE_BETAS = ( def _claude_code_turn(system_output_config): - """Claude Code changes effort mid-conversation by appending a ``role: system`` - message that carries ``output_config``; the rest of the body is unchanged.""" return [ {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, - {"role": "system", "content": [{"type": "text", "text": "# Environment"}], "output_config": system_output_config}, + { + "role": "system", + "content": [{"type": "text", "text": "# Environment"}], + "output_config": system_output_config, + }, ] @@ -52,17 +54,12 @@ def bundled_beta_allowlist(monkeypatch): def test_per_message_output_config_adds_per_turn_control_beta(): - """Anthropic rejects ``messages.N.output_config`` with a 400 unless the request - opts into ``per-turn-control-2026-07-01``, so a body carrying it must get the beta - even from a client whose ``anthropic-beta`` header never reached the proxy.""" headers = _validate(_claude_code_turn({"effort": "high"})) assert PER_TURN_CONTROL in _betas(headers) def test_top_level_output_config_alone_does_not_add_per_turn_control_beta(): - """Effort set once at the top level is plain GA request shape; only a message-level - ``output_config`` needs the per-turn beta.""" headers = _validate([{"role": "user", "content": "Hello"}]) assert PER_TURN_CONTROL not in _betas(headers) @@ -75,8 +72,6 @@ def test_string_messages_are_skipped_when_scanning_for_output_config(): def test_forwarded_client_betas_survive_alongside_the_added_one(): - """With ``forward_client_headers_to_llm_api`` on, Claude Code's own beta list - arrives on the request; it is merged with the auto-added set, not replaced.""" headers = _validate(_claude_code_turn({"effort": "low"}), headers={"anthropic-beta": CLAUDE_CODE_BETAS}) assert _betas(headers) >= set(CLAUDE_CODE_BETAS.split(",")) @@ -84,9 +79,6 @@ def test_forwarded_client_betas_survive_alongside_the_added_one(): def test_case_variant_client_beta_header_is_merged(): - """A client or config can spell the header ``Anthropic-Beta``; the proxy forwards it as - is, so the merge must read it whatever the casing and write one canonical header instead - of a lowercase one that clobbers it.""" headers = _validate( _claude_code_turn({"effort": "low"}), headers={"Anthropic-Beta": "interleaved-thinking-2025-05-14"} ) @@ -96,9 +88,6 @@ def test_case_variant_client_beta_header_is_merged(): def test_added_per_turn_control_beta_survives_the_anthropic_allowlist(): - """The proxy filters ``anthropic-beta`` against the bundled allowlist right after - the headers are built. A name missing from it is dropped silently, which would turn - the auto-added beta back into the original 400, so the allowlist must carry it.""" headers = _validate(_claude_code_turn({"effort": "high"})) filtered = update_headers_with_filtered_beta(headers=headers, provider="anthropic") @@ -114,8 +103,6 @@ def test_per_turn_control_beta_is_dropped_for_providers_without_it(provider): def test_json_provider_passthrough_adds_per_turn_control_beta(): - """The generic Anthropic-compatible provider path builds its headers separately from - the Anthropic config and must scan the messages the same way.""" config = JSONProviderAnthropicMessagesConfig( SimpleProviderConfig( "anthropic_like",