mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
fix(bedrock): forward the per-turn-control beta to Bedrock Invoke (#43086)
Backport of #43086 to lts/1.108.x.
Cherry-picked from 08f2a98250 (main).
Conflicts resolved for the line: update_headers_with_filtered_beta keeps the line's (headers, provider) signature and gains only keep=, the messages handler passes keep= at the line's existing filter call, DEPLOYMENT_SCOPED_CAPABILITY_FIELDS adds supports_per_turn_control to the line's own set, the Invoke config (anthropic_claude3_transformation.py on the line) gains the _MODEL_INFO adapter main already had, and the test file gains main's ScriptedBedrockInvoke and _ANTHROPIC_MESSAGE helpers the new tests use.
This commit is contained in:
parent
bf06bf1edb
commit
28abdf7dfe
10 changed files with 220 additions and 11 deletions
|
|
@ -354,6 +354,7 @@ def get_provider_beta_header(
|
|||
def update_headers_with_filtered_beta(
|
||||
headers: dict,
|
||||
provider: str,
|
||||
keep: frozenset[str] = frozenset(),
|
||||
) -> dict:
|
||||
"""
|
||||
Update headers dict by filtering and transforming anthropic-beta header values.
|
||||
|
|
@ -362,6 +363,7 @@ def update_headers_with_filtered_beta(
|
|||
Args:
|
||||
headers: Request headers dict (will be modified in place)
|
||||
provider: Provider name
|
||||
keep: Betas the routed model accepts, forwarded as sent whatever the row says
|
||||
|
||||
Returns:
|
||||
Updated headers dict
|
||||
|
|
@ -371,12 +373,19 @@ 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=provider,
|
||||
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=provider,
|
||||
),
|
||||
*(b for b in beta_values if b in keep),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Update or remove the header
|
||||
|
|
|
|||
|
|
@ -138,6 +138,9 @@ class BaseAnthropicMessagesConfig(ABC):
|
|||
"""
|
||||
return True
|
||||
|
||||
def model_specific_anthropic_betas(self, model: str, litellm_params: GenericLiteLLMParams) -> frozenset[str]:
|
||||
return frozenset()
|
||||
|
||||
def handles_web_search_natively(self) -> bool:
|
||||
"""
|
||||
Whether the upstream this config routes to executes ``web_search`` tools
|
||||
|
|
|
|||
|
|
@ -1270,6 +1270,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``
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from types import MappingProxyType
|
|||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -37,6 +38,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation
|
|||
from litellm.llms.bedrock.common_utils import (
|
||||
BedrockError,
|
||||
apply_bedrock_invoke_structured_output,
|
||||
bedrock_supports_per_turn_control,
|
||||
bedrock_supports_tool_search,
|
||||
ensure_bedrock_anthropic_messages_tool_names,
|
||||
get_anthropic_beta_from_headers,
|
||||
|
|
@ -70,6 +72,9 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
_MODEL_INFO: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(Mapping[str, object] | None)
|
||||
|
||||
|
||||
class AmazonAnthropicClaudeMessagesConfig(
|
||||
AnthropicMessagesConfig,
|
||||
AmazonInvokeConfig,
|
||||
|
|
@ -91,6 +96,13 @@ class AmazonAnthropicClaudeMessagesConfig(
|
|||
def beta_headers_provider(self) -> str:
|
||||
return self.custom_llm_provider or "bedrock"
|
||||
|
||||
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(
|
||||
|
|
@ -517,6 +529,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")
|
||||
|
|
@ -578,16 +591,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(
|
||||
|
|
@ -770,6 +789,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:
|
||||
|
|
|
|||
|
|
@ -2270,7 +2270,11 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
|
||||
if anthropic_messages_provider_config.should_filter_anthropic_beta_headers():
|
||||
headers = update_headers_with_filtered_beta(headers=headers, provider=custom_llm_provider)
|
||||
headers = update_headers_with_filtered_beta(
|
||||
headers=headers,
|
||||
provider=custom_llm_provider,
|
||||
keep=anthropic_messages_provider_config.model_specific_anthropic_betas(model, litellm_params),
|
||||
)
|
||||
|
||||
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
|
||||
|
||||
|
|
|
|||
|
|
@ -64002,6 +64002,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/",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
|
|||
supports_legacy_thinking: ReadOnly[bool | None]
|
||||
thinking_always_on: 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_none_reasoning_effort: bool | None
|
||||
|
|
@ -4083,7 +4084,9 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
|
||||
DEPLOYMENT_SCOPED_PRICING_FIELDS: Final[frozenset[str]] = frozenset({"off_peak_pricing"})
|
||||
|
||||
DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset({"supports_regex_lookaround"})
|
||||
DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{"supports_regex_lookaround", "supports_per_turn_control"}
|
||||
)
|
||||
|
||||
DEPLOYMENT_SCOPED_TOKEN_COUNT_FIELDS: Final[frozenset[str]] = frozenset({"token_count_framing", "token_count_model"})
|
||||
|
||||
|
|
|
|||
|
|
@ -6749,6 +6749,7 @@ def get_model_info_helper(
|
|||
supports_legacy_thinking=_model_info.get("supports_legacy_thinking", None),
|
||||
thinking_always_on=_model_info.get("thinking_always_on", 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),
|
||||
|
|
|
|||
|
|
@ -64002,6 +64002,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/",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import os
|
|||
import struct
|
||||
import zlib
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
|
|
@ -2309,6 +2310,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
|
||||
|
|
@ -3837,6 +3864,121 @@ def test_bedrock_removed_tool_change_does_not_add_beta(explicit_beta: bool) -> N
|
|||
assert result.get("anthropic_beta", []).count(beta) == int(explicit_beta)
|
||||
|
||||
|
||||
_ANTHROPIC_MESSAGE: Final = {
|
||||
"id": "msg_scripted",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-scripted",
|
||||
"content": [{"type": "text", "text": '{"summary": "sunny", "kind": 1}'}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ScriptedBedrockInvoke:
|
||||
replies: tuple[tuple[int, Mapping[str, object] | bytes], ...]
|
||||
bodies: tuple[dict[str, object], ...] = ()
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.bodies = (*self.bodies, REASONING_BODY_ADAPTER.validate_json(request.content))
|
||||
status_code, body = self.replies[len(self.bodies) - 1]
|
||||
if isinstance(body, bytes):
|
||||
return httpx.Response(status_code, content=body)
|
||||
return httpx.Response(status_code, json=body)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trace", ["enabled", "disabled"])
|
||||
def test_bedrock_messages_applies_the_deployment_guardrail_config_as_invoke_headers(trace: str) -> None:
|
||||
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue