fix(anthropic): add the per-turn-control beta when a message carries output_config

This commit is contained in:
mateo-berri 2026-09-14 22:01:47 -07:00
parent b4cff58be7
commit 19dc66a48c
9 changed files with 154 additions and 16 deletions

View file

@ -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",

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)