Merge pull request #41189 from BerriAI/litellm_per_turn_control_beta

fix(anthropic): add the per-turn-control beta when a message carries output_config
This commit is contained in:
Mateo Wang 2026-09-15 11:53:01 -07:00 committed by GitHub
commit d3929287fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 166 additions and 24 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,24 +679,30 @@ 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()
# 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
@ -722,24 +734,18 @@ 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 beta_values:
headers["anthropic-beta"] = ",".join(sorted(beta_values))
if AnthropicModelInfo().is_tool_search_used(tools):
beta_values.add(get_tool_search_beta_header(custom_llm_provider))
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

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,125 @@
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):
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():
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():
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():
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_case_variant_client_beta_header_is_merged():
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():
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():
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)