fix(bedrock): forward the per-turn-control beta to Bedrock Invoke (#43086)
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Cost map pins recheck / rerun-cost-map-guard (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust DB / db-queries (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
unit / rust-bridge (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.0.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.25.0 compatibility (push) Blocked by required conditions
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
VS Code Extension / vscode-extension (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run

* fix(bedrock): forward the per-turn-control beta to Bedrock Invoke

Claude Code sends `anthropic-beta: per-turn-control-2026-07-01` together
with a mid-conversation `role: system` message carrying a message-level
`output_config` (for example an effort change). The beta header config
mapped the beta to null for `bedrock`, so LiteLLM dropped it while
keeping the message in place on models flagged
`supports_mid_conversation_system`, and Bedrock Invoke rejected the turn
with "messages.N.output_config: Extra inputs are not permitted".

Direct Bedrock InvokeModel probes with a synthetic request show Claude
Opus 5.5 accepts that message shape only with this beta, and that Claude
Haiku 4.5, Sonnet 4.6, Opus 4.8, Opus 5, and Sonnet 5 accept the beta on
a plain request, so map it to itself for `bedrock`. `bedrock_converse`
is unchanged.

Generated with AI

Co-Authored-By: Claude Code

* test(anthropic): expect the per-turn-control beta to reach Bedrock Invoke

Generated with AI

Co-Authored-By: Claude Code

* test(bedrock): drop the duplicate per-turn-control filter test and cite the live check

* fix(rust): allow the per-turn-control beta on bedrock in the Rust beta table

The Rust table must match litellm/anthropic_beta_headers_config.json cell for cell, and the JSON now maps the beta for bedrock. Also type the new Bedrock Invoke test.

* fix(bedrock): forward per-turn-control on Bedrock Invoke only where the cost map flags the model

Bedrock InvokeModel accepts per-turn-control-2026-07-01 on Claude 4.5 and newer and answers 400 invalid beta flag
on Claude 3 Haiku. Released proxies load the beta map from main, so forwarding it in the bedrock row would turn
Claude 3 Haiku requests on every running release from 200 into 400. Keep that row null and forward the client's
beta from the Invoke transformation when a new claude-per-turn-control cost map rule sets supports_per_turn_control.

* fix(bedrock): keep the per-turn-control beta through the messages handler filter on Bedrock Invoke

The /v1/messages handler filtered client betas against the beta map before the
Bedrock Invoke config could add per-turn-control back for models the cost map
flags, so the beta never reached Bedrock. Provider configs now name betas their
model accepts beyond the beta map, and the handler filter keeps those

* fix(bedrock): keep a deployment's per-turn-control flag on that deployment

supports_per_turn_control is now deployment-scoped, so a deployment's opt-in or opt-out no longer changes the beta forward for other deployments of the same Bedrock model. The forward reads the deployment's own model_info first, then the model map.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
Scott Wilson 2026-11-06 01:29:17 -05:00 • committed by GitHub
parent b1b236826a
commit 08f2a98250
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 185 additions and 10 deletions

View file

@ -427,6 +427,7 @@ def update_headers_with_filtered_beta(
provider: str,
api_base: str | None = None,
unlisted: UnlistedBetas = "drop",
keep: frozenset[str] = frozenset(),
) -> dict:
"""
Update headers dict by filtering and transforming anthropic-beta header values.
@ -437,6 +438,7 @@ def update_headers_with_filtered_beta(
provider: Provider name
api_base: Deployment api_base, used to pick the allowlist row (Foundry hosts use ``azure_ai``)
unlisted: Whether a beta missing from the row is dropped or forwarded as sent
keep: Betas the routed model accepts, forwarded as sent whatever the row says
Returns:
Updated headers dict
@ -446,13 +448,20 @@ def update_headers_with_filtered_beta(
return headers
# Parse existing beta headers
beta_values: Final = [b.strip() for b in existing_beta.split(",") if b.strip()]
beta_values: Final[tuple[str, ...]] = tuple(b.strip() for b in existing_beta.split(",") if b.strip())
# Filter and transform based on provider
filtered_beta_values: Final = filter_and_transform_beta_headers(
beta_headers=beta_values,
provider=resolve_beta_provider(provider, api_base),
unlisted=unlisted,
filtered_beta_values: Final = tuple(
dict.fromkeys(
[
*filter_and_transform_beta_headers(
beta_headers=[b for b in beta_values if b not in keep],
provider=resolve_beta_provider(provider, api_base),
unlisted=unlisted,
),
*(b for b in beta_values if b in keep),
]
)
)
# Update or remove the header

View file

@ -139,6 +139,9 @@ class BaseAnthropicMessagesConfig(ABC):
"""
return True
def model_specific_anthropic_betas(self, model: str, litellm_params: GenericLiteLLMParams) -> frozenset[str]:
return frozenset()
def supports_safeguards(self) -> bool:
return True

View file

@ -1708,6 +1708,15 @@ def bedrock_supports_tool_search(model: str) -> bool:
return AnthropicModelInfo.supports_model_capability(model, "supports_tool_search", "bedrock")
def bedrock_supports_per_turn_control(model: str, model_info: Mapping[str, object] | None) -> bool:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
deployment_flag: Final = (model_info or {}).get("supports_per_turn_control")
if isinstance(deployment_flag, bool):
return deployment_flag
return AnthropicModelInfo.supports_model_capability(model, "supports_per_turn_control", "bedrock")
def supports_bedrock_prompt_cache_ttl(model: str) -> bool:
"""
Whether Bedrock accepts a ``ttl`` ("5m"/"1h") on prompt ``cache_control``

View file

@ -42,6 +42,7 @@ from litellm.llms.bedrock.common_utils import (
apply_bedrock_invoke_structured_output,
bedrock_native_output_format,
bedrock_native_output_retry_requested,
bedrock_supports_per_turn_control,
bedrock_supports_tool_search,
ensure_bedrock_anthropic_messages_tool_names,
for_bedrock_json_tool_retry,
@ -113,6 +114,13 @@ class AmazonAnthropicClaudeMessagesConfig(
def drops_dangerous_tool_use_beta_without_safeguards(self) -> bool:
return True
def model_specific_anthropic_betas(self, model: str, litellm_params: GenericLiteLLMParams) -> frozenset[str]:
if bedrock_supports_per_turn_control(
model, _MODEL_INFO.validate_python(getattr(litellm_params, "model_info", None))
):
return frozenset({ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value})
return frozenset()
BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS = frozenset(BedrockInvokeAnthropicMessagesRequest.__annotations__.keys())
def get_error_class(
@ -574,6 +582,7 @@ class AmazonAnthropicClaudeMessagesConfig(
headers: dict,
anthropic_messages_request: dict,
injected_thinking_for_clear_thinking: bool,
litellm_params: GenericLiteLLMParams,
) -> list[str]:
anthropic_model_info: Final = AnthropicModelInfo()
tools: Final = anthropic_messages_optional_request_params.get("tools")
@ -642,16 +651,22 @@ class AmazonAnthropicClaudeMessagesConfig(
if "tool-search-tool-2025-10-19" in beta_set:
beta_set.add("tool-examples-2025-10-29")
model_supported_betas: Final = self.model_specific_anthropic_betas(model, litellm_params) & user_beta_set
beta_provider: Final = self.beta_headers_provider
filtered_betas: Final = sorted(
filter_and_transform_beta_headers(
beta_headers=list(beta_set),
provider=beta_provider,
)
{
*filter_and_transform_beta_headers(
beta_headers=list(beta_set),
provider=beta_provider,
),
*model_supported_betas,
}
)
dropped_user_betas: Final = sorted(
b for b in user_beta_set if not filter_and_transform_beta_headers([b], provider=beta_provider)
b
for b in user_beta_set - model_supported_betas
if not filter_and_transform_beta_headers([b], provider=beta_provider)
)
if dropped_user_betas:
verbose_logger.warning(
@ -837,6 +852,7 @@ class AmazonAnthropicClaudeMessagesConfig(
headers=headers,
anthropic_messages_request=anthropic_messages_request,
injected_thinking_for_clear_thinking=injected_thinking_for_clear_thinking,
litellm_params=litellm_params,
)
if filtered_betas:

View file

@ -2642,6 +2642,7 @@ class BaseLLMHTTPHandler:
provider=custom_llm_provider,
api_base=affinity_api_base,
unlisted="drop" if provider_filters_betas else "forward",
keep=anthropic_messages_provider_config.model_specific_anthropic_betas(model, litellm_params),
)
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase

View file

@ -64871,6 +64871,15 @@
"supports_tool_search": true
}
},
{
"name": "claude-per-turn-control",
"pattern": "claude-[a-z]+-(?:4[-._](?:[5-9]|[1-9]\\d)(?!\\d)|[5-9](?!\\d)(?:[-._]\\d{1,2}(?!\\d))?)",
"fill_missing_for_providers": ["bedrock", "bedrock_converse"],
"description": "Claude at version 4.5 or higher, with the same id shapes and version boundaries as claude-tool-search. On 2026-11-05 Bedrock InvokeModel answered 200 with the per-turn-control-2026-07-01 beta on every Claude 4.5 and newer model a QA account reached across 13 regions, and 400 invalid beta flag on Claude 3 Haiku in ca-central-1. The jp., au. and in. profiles answered 200 too, AWS lists no apac. profile for Claude 4.5 or newer, and GovCloud was not tested. Claude 3.5, 3.7, Sonnet 4, Opus 4 and Opus 4.1 were unreachable, so they keep the beta dropped as before. The flag means Bedrock accepts the beta name on the model, as supports_tool_search does for the tool search beta, and Bedrock Invoke forwards the beta only where it is set. It does not promise per-turn output_config works, since on 2026-11-05 Opus 4.8, Opus 5, Sonnet 5 and Fable 5 rejected it with or without the beta. A deployment turns the forward off with model_info supports_per_turn_control false.",
"model_info": {
"supports_per_turn_control": true
}
},
{
"name": "wandb-reasoning-baseline",
"pattern": "^wandb/",

View file

@ -226,6 +226,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
thinking_always_on: ReadOnly[bool | None]
thinking_on_by_default: ReadOnly[bool | None]
supports_tool_search: bool | None
supports_per_turn_control: ReadOnly[bool | None]
supports_mid_conversation_system: bool | None
supports_url_context: bool | None
supports_continuation: ReadOnly[bool | None]
@ -4302,6 +4303,7 @@ DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset(
"supports_regex_lookaround",
"supports_assistant_prefill",
"supports_bedrock_native_structured_output",
"supports_per_turn_control",
"supports_pdf_input",
"supports_continuation",
"reasoning_modes",

View file

@ -7600,6 +7600,7 @@ def get_model_info_helper(
thinking_always_on=_model_info.get("thinking_always_on", None),
thinking_on_by_default=_model_info.get("thinking_on_by_default", None),
supports_tool_search=_model_info.get("supports_tool_search", None),
supports_per_turn_control=_model_info.get("supports_per_turn_control", None),
supports_mid_conversation_system=_model_info.get("supports_mid_conversation_system", None),
supports_anthropic_thinking_payload=_model_info.get("supports_anthropic_thinking_payload", None),
supports_anthropic_compaction=_model_info.get("supports_anthropic_compaction", None),

View file

@ -64871,6 +64871,15 @@
"supports_tool_search": true
}
},
{
"name": "claude-per-turn-control",
"pattern": "claude-[a-z]+-(?:4[-._](?:[5-9]|[1-9]\\d)(?!\\d)|[5-9](?!\\d)(?:[-._]\\d{1,2}(?!\\d))?)",
"fill_missing_for_providers": ["bedrock", "bedrock_converse"],
"description": "Claude at version 4.5 or higher, with the same id shapes and version boundaries as claude-tool-search. On 2026-11-05 Bedrock InvokeModel answered 200 with the per-turn-control-2026-07-01 beta on every Claude 4.5 and newer model a QA account reached across 13 regions, and 400 invalid beta flag on Claude 3 Haiku in ca-central-1. The jp., au. and in. profiles answered 200 too, AWS lists no apac. profile for Claude 4.5 or newer, and GovCloud was not tested. Claude 3.5, 3.7, Sonnet 4, Opus 4 and Opus 4.1 were unreachable, so they keep the beta dropped as before. The flag means Bedrock accepts the beta name on the model, as supports_tool_search does for the tool search beta, and Bedrock Invoke forwards the beta only where it is set. It does not promise per-turn output_config works, since on 2026-11-05 Opus 4.8, Opus 5, Sonnet 5 and Fable 5 rejected it with or without the beta. A deployment turns the forward off with model_info supports_per_turn_control false.",
"model_info": {
"supports_per_turn_control": true
}
},
{
"name": "wandb-reasoning-baseline",
"pattern": "^wandb/",

View file

@ -2370,6 +2370,32 @@ def test_bedrock_invoke_transform_hoists_only_leading_system_run(local_model_cos
]
def test_bedrock_invoke_transform_forwards_per_turn_control_beta_with_message_output_config(
local_model_cost_map: None, local_beta_headers_config: None
) -> None:
"""A live InvokeModel call on 2026-11-05 answered 200 with per-turn-control-2026-07-01 next to a message
``output_config`` on Opus 5.5, so the client's beta and that message must both reach Bedrock."""
from litellm.types.router import GenericLiteLLMParams
messages: Final = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "system", "content": [], "output_config": {"effort": "low"}},
{"role": "user", "content": "continue"},
]
result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request(
model="us.anthropic.claude-opus-5-5",
messages=copy.deepcopy(messages),
anthropic_messages_optional_request_params={"max_tokens": 256, "stream": False},
litellm_params=GenericLiteLLMParams(),
headers={"anthropic-beta": "per-turn-control-2026-07-01"},
)
assert result["messages"] == messages
assert result["anthropic_beta"].count("per-turn-control-2026-07-01") == 1
def test_bedrock_invoke_transform_converts_mid_conversation_system_for_older_claude(local_model_cost_map):
"""Invoke rejects ``role: "system"`` in every position on Opus 4.7, Sonnet
4.6, Haiku 4.5, etc. ("role 'system' is not supported on this model"), but
@ -4093,6 +4119,96 @@ async def _send_bedrock_messages(
return bedrock, None
@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config")
@pytest.mark.parametrize(
("model", "headers", "forwarded"),
[
("anthropic.claude-3-haiku-20240307-v1:0", {"anthropic-beta": "per-turn-control-2026-07-01"}, False),
("us.anthropic.claude-opus-4-1-20250805-v1:0", {"anthropic-beta": "per-turn-control-2026-07-01"}, False),
("us.anthropic.claude-sonnet-4-5-20250929-v1:0", {"anthropic-beta": "per-turn-control-2026-07-01"}, True),
("jp.anthropic.claude-opus-4-6-v1", {"anthropic-beta": "per-turn-control-2026-07-01"}, True),
("au.anthropic.claude-opus-4-8", {"anthropic-beta": "per-turn-control-2026-07-01"}, True),
("eu.anthropic.claude-opus-5-5", {"anthropic-beta": "per-turn-control-2026-07-01"}, True),
("global.anthropic.claude-sonnet-5-5", {"anthropic-beta": "per-turn-control-2026-07-01"}, True),
("us.anthropic.claude-newfamily-6", {"anthropic-beta": "per-turn-control-2026-07-01"}, True),
("us.anthropic.claude-sonnet-5-5", {}, False),
],
)
async def test_bedrock_invoke_messages_forward_per_turn_control_beta_only_where_the_model_map_flags_it(
model: str, headers: dict[str, str], forwarded: bool
) -> None:
"""On 2026-11-05 Bedrock InvokeModel answered 400 ``invalid beta flag`` to per-turn-control-2026-07-01 on Claude 3
Haiku and 200 on every Claude 4.5 and newer model a QA account reached in 13 regions and in the jp., au. and in.
profiles."""
bedrock: Final = ScriptedBedrockInvoke(replies=((200, _ANTHROPIC_MESSAGE),))
await litellm.anthropic_messages(
model=f"bedrock/invoke/{model}",
messages=[{"role": "user", "content": "hi"}],
max_tokens=16,
extra_headers=headers,
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
aws_region_name="us-east-1",
client=AsyncHTTPHandler(transport=httpx.MockTransport(bedrock)),
)
(sent,) = bedrock.bodies
sent_betas: Final = TypeAdapter(tuple[str, ...]).validate_python(sent.get("anthropic_beta", ()))
assert ("per-turn-control-2026-07-01" in sent_betas) is forwarded
@pytest.mark.parametrize(
("model", "flag", "expected"),
[
("us.anthropic.claude-opus-5-5", False, (False, True)),
("anthropic.claude-3-haiku-20240307-v1:0", True, (True, False)),
],
ids=["opt_out", "opt_in"],
)
@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config")
async def test_bedrock_invoke_messages_per_turn_control_flag_stays_on_its_own_deployment(
respx_mock: respx.Router,
monkeypatch: pytest.MonkeyPatch,
model: str,
flag: bool,
expected: tuple[bool, bool],
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
bedrock: Final = ScriptedBedrockInvoke(replies=((200, _ANTHROPIC_MESSAGE), (200, _ANTHROPIC_MESSAGE)))
respx_mock.post(url__regex=r"https://bedrock-runtime\..*").mock(side_effect=bedrock)
router: Final = litellm.Router(
model_list=[
{
"model_name": model_name,
"litellm_params": {
"model": f"bedrock/invoke/{model}",
"aws_access_key_id": "test-access-key",
"aws_secret_access_key": "test-secret-key",
"aws_region_name": "us-east-1",
},
"model_info": model_info,
}
for model_name, model_info in (("flagged", {"supports_per_turn_control": flag}), ("default", {}))
],
num_retries=0,
)
for model_name in ("flagged", "default"):
await router.aanthropic_messages(
model=model_name,
messages=[{"role": "user", "content": "hi"}],
max_tokens=16,
extra_headers={"anthropic-beta": "per-turn-control-2026-07-01"},
)
forwarded: Final = tuple(
"per-turn-control-2026-07-01" in TypeAdapter(tuple[str, ...]).validate_python(sent.get("anthropic_beta", ()))
for sent in bedrock.bodies
)
assert forwarded == expected
_OPT_IN_DEPLOYMENTS: Final = pytest.mark.parametrize(
("model", "model_info"),
[