fix(vertex_ai): forward the per-turn-control beta for per-message output_config (#43558)

Claude Code attaches output_config to mid-conversation system messages and
sends the per-turn-control-2026-07-01 beta with it. The Vertex beta map
dropped that beta, so Vertex rejected the body with
'messages.N.output_config: Extra inputs are not permitted'.

Forward the beta for vertex_ai, the way azure_ai already does, and add it on
the Vertex Messages path whenever a message carries output_config.
This commit is contained in:
Chase 2026-09-28 21:35:08 -07:00 • committed by GitHub
parent 60fca8298e
commit 0fb93ed9de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 63 additions and 4 deletions

View file

@ -194,7 +194,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,
"per-turn-control-2026-07-01": "per-turn-control-2026-07-01",
"prompt-caching-scope-2026-01-05": null,
"skills-2025-10-02": null,
"structured-outputs-2025-11-13": null,

View file

@ -3,6 +3,7 @@ from typing import Any, Final
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.anthropic.pass_through.messages.transformation import (
AnthropicMessagesConfig,
_messages_carry_output_config,
)
from litellm.types.llms.anthropic import (
ANTHROPIC_BETA_HEADER_VALUES,
@ -111,6 +112,9 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert
if optional_params.get("safeguards") is not None:
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value)
if _messages_carry_output_config(messages):
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value)
if beta_values:
headers["anthropic-beta"] = ",".join(beta_values)

View file

@ -95,15 +95,16 @@ def test_added_per_turn_control_beta_survives_the_anthropic_allowlist():
assert PER_TURN_CONTROL in _betas(filtered)
@pytest.mark.parametrize("provider", ["bedrock", "bedrock_converse", "vertex_ai", "databricks"])
@pytest.mark.parametrize("provider", ["bedrock", "bedrock_converse", "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_per_turn_control_beta_is_forwarded_for_azure_ai():
filtered = update_headers_with_filtered_beta(headers={"anthropic-beta": PER_TURN_CONTROL}, provider="azure_ai")
@pytest.mark.parametrize("provider", ["azure_ai", "vertex_ai"])
def test_per_turn_control_beta_is_forwarded_for_providers_with_it(provider):
filtered = update_headers_with_filtered_beta(headers={"anthropic-beta": PER_TURN_CONTROL}, provider=provider)
assert _betas(filtered) == {PER_TURN_CONTROL}

View file

@ -126,6 +126,60 @@ def test_no_safeguards_leaves_dangerous_tool_use_beta_header_out():
assert "dangerous-tool-use-2026-09-03" not in updated_headers.get("anthropic-beta", "")
def _validate_vertex_headers(client_headers, messages):
config = VertexAIPartnerModelsAnthropicMessagesConfig()
litellm_params = {
"vertex_ai_project": "test-project",
"vertex_ai_location": "global",
"vertex_credentials": "{}",
}
with (
patch.object(config, "_ensure_access_token", return_value=("token", "test-project")),
patch.object(config, "get_complete_vertex_url", return_value="https://mock-url"),
):
updated_headers, _ = config.validate_anthropic_messages_environment(
headers=client_headers,
model="claude-opus-5-5",
messages=messages,
optional_params={"max_tokens": 64},
litellm_params=litellm_params,
api_base=None,
)
return updated_headers
@pytest.mark.parametrize(
"client_headers",
[{"anthropic-beta": "per-turn-control-2026-07-01"}, {}],
ids=["client_sends_beta", "client_omits_beta"],
)
def test_per_message_output_config_reaches_vertex_with_per_turn_control_beta(client_headers, monkeypatch):
"""Vertex rejects a message-level `output_config` as an extra input unless the per-turn-control beta is present, so the beta must survive the Vertex beta filter."""
from litellm import anthropic_beta_headers_manager
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True")
monkeypatch.setattr(anthropic_beta_headers_manager, "_BETA_HEADERS_CONFIG", None)
messages = [
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
{"role": "system", "content": [{"type": "text", "text": "# Environment"}], "output_config": {"effort": "low"}},
]
filtered = update_headers_with_filtered_beta(
headers=_validate_vertex_headers(client_headers, messages), provider="vertex_ai"
)
assert filtered["anthropic-beta"].split(",").count("per-turn-control-2026-07-01") == 1
def test_no_per_message_output_config_leaves_per_turn_control_beta_out():
headers = _validate_vertex_headers({}, [{"role": "user", "content": "Hello"}])
assert "per-turn-control-2026-07-01" not in headers.get("anthropic-beta", "")
def test_web_search_header_not_added_without_tool():
"""Test that beta header is NOT added when web search tool is not present"""
config = VertexAIPartnerModelsAnthropicMessagesConfig()