From b25ea48c4b194dbae4d858839619c426f1258ac6 Mon Sep 17 00:00:00 2001 From: tavren27 <348969968+tavren27@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:25:31 -0700 Subject: [PATCH] fix(anthropic)!: drop beta-gated body fields under drop_params (#48793) * fix(anthropic): strip beta-gated body fields when a leg drops the anthropic-beta When a provider leg drops an anthropic-beta value (the chat handler's allowlist filter, /v1/messages' filtered headers, Bedrock Converse's additionalModelRequestFields), also drop the body fields that beta gates (thinking.block_binding, thinking.display=updates, mid-conversation system clear_at, output_config, tool_addition/tool_removal blocks) so the request degrades on that leg instead of failing with a provider 400. Copy-on-write so a fallback leg still sends the full request, and each strip is logged at WARNING. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): prove a leg that drops a beta sends neither the beta nor clear_at Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(bedrock): strip beta-gated fields against the final body anthropic_beta list Converse counts the body anthropic_beta list as active, Invoke strips after its auto-injected betas are known and injects thinking-binding-controls for thinking.block_binding, test helpers are fully typed Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(anthropic): strip beta-gated system fields before an older model's system turn becomes a user turn Models without supports_mid_conversation_system send no mid-conversation beta, so tool_addition and tool_removal blocks are dropped (and logged) before the turn is converted instead of riding along as user content. The Invoke output_config regression test reads the real cost map instead of patching _bedrock_model_supports Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(anthropic): pin the integration proxy to the bundled beta allowlist and type the helper locals Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(anthropic): annotate every new test local with Final and type the fixtures and returns Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(anthropic): strip beta-gated body fields only under drop_params Without drop_params the request now goes out as sent and the provider answers its own 400, like any other unsupported param. inline-tools-2026-09-15 also unlocks tool_addition and tool_removal blocks, so a leg that carries it keeps them. * fix(anthropic): strip beta-gated fields on Foundry chat and Bedrock chat Invoke under drop_params Foundry chat and Bedrock chat Invoke now drop beta-gated body fields the same way the other legs do when drop_params is on and the request does not send the gating beta. The mid-conversation system normalizer validates only on the conversion path, and the strip WARNING names the beta the caller can send to keep the field. * fix(anthropic): send the thinking betas for block_binding and display updates, strip Databricks chat thinking Every Claude leg now adds the thinking-binding-controls beta when thinking carries block_binding and the display-updates beta for display updates, so those fields go through instead of a 400. Databricks chat rejects both fields with or without the beta, so it drops them under drop_params. A system turn the strip empties stays when it still carries output_config, and the drop warning names both causes. * fix(anthropic): send the thinking betas on every leg and keep fields where the beta is unknown Vertex messages and Claude Platform chat now add the thinking betas when thinking carries block_binding or display updates. Converse sends only the caller's header betas when it has them. A provider with no row in the beta config forwards beta-gated fields instead of dropping them, an emptied trailing system turn that would leave an assistant prefill is sent as is, and the prompt cache baseline body matches the body that goes out. The drop WARNING now names the beta that unlocks each field * fix(anthropic): filter Converse body betas, keep Foundry auto betas, read the bundled beta config for row checks * fix(anthropic): validate messages only when a beta-gated drop runs A non-object messages entry on a Claude model without mid-conversation system support raised a raw pydantic ValidationError before the beta-config and drop_params checks. The strip now passes the messages through untouched unless it drops something, so the provider's 400 reaches SDK callers as BadRequestError again * fix(anthropic): keep system blocks with a non-string type instead of raising --------- Co-authored-by: mateo Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- litellm/anthropic_beta_gated_fields.py | 325 ++++++++++++++ litellm/anthropic_beta_headers_config.json | 1 + litellm/anthropic_beta_headers_manager.py | 9 +- litellm/llms/anthropic/chat/handler.py | 12 +- litellm/llms/anthropic/common_utils.py | 28 +- .../messages/native/transformation.py | 87 +++- .../llms/anthropic/prompt_cache_prediction.py | 5 +- litellm/llms/azure_ai/anthropic/handler.py | 14 +- .../anthropic/messages_transformation.py | 4 +- .../llms/azure_ai/anthropic/transformation.py | 4 +- .../bedrock/chat/converse_transformation.py | 36 +- .../anthropic_claude_transformation.py | 14 +- .../bedrock/claude_platform/transformation.py | 1 + .../anthropic_claude_transformation.py | 26 +- .../llms/databricks/chat/transformation.py | 9 + .../databricks/messages/transformation.py | 6 +- .../anthropic/messages/transformation.py | 10 +- .../anthropic/transformation.py | 1 + litellm/types/llms/anthropic.py | 1 + .../test_beta_gated_fields_wire.py | 183 ++++++++ .../chat/test_anthropic_chat_handler.py | 88 +++- .../test_anthropic_messages_transformation.py | 133 ++++++ .../anthropic/test_anthropic_common_utils.py | 21 +- .../test_anthropic_prompt_cache_prediction.py | 414 +++++++++++++----- .../claude/test_azure_anthropic_handler.py | 69 +++ ...mations_anthropic_claude_transformation.py | 22 + .../chat/test_converse_transformation.py | 126 +++++- .../test_anthropic_claude_transformation.py | 94 +++- .../bedrock/test_claude_platform_provider.py | 20 + .../test_databricks_chat_transformation.py | 35 ++ ...test_databricks_messages_transformation.py | 29 +- ...artner_models_anthropic_messages_config.py | 37 +- ...partner_models_anthropic_transformation.py | 19 + .../unit/test_anthropic_beta_gated_fields.py | 301 +++++++++++++ 34 files changed, 2005 insertions(+), 179 deletions(-) create mode 100644 litellm/anthropic_beta_gated_fields.py create mode 100644 tests/integration/messages_endpoint/test_beta_gated_fields_wire.py create mode 100644 tests/unit/test_anthropic_beta_gated_fields.py diff --git a/litellm/anthropic_beta_gated_fields.py b/litellm/anthropic_beta_gated_fields.py new file mode 100644 index 00000000000..fc36699bc93 --- /dev/null +++ b/litellm/anthropic_beta_gated_fields.py @@ -0,0 +1,325 @@ +"""Body fields that an ``anthropic-beta`` value gates. + +``anthropic_beta_headers_config.json`` says which beta each provider leg +carries. When a leg drops a beta, the request fields that beta unlocks +would make the provider 400. Under ``drop_params`` the same request degrades on +that leg by dropping those fields too; without it the request goes out as sent +and the provider answers its own 400, like any unsupported param. Every helper +here is copy-on-write: the caller's containers are returned untouched when +nothing is stripped, and a shallow copy is returned otherwise, so a fallback +leg still sends the full request. +""" + +from collections.abc import Mapping, Sequence, Set +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final + +from pydantic import TypeAdapter, ValidationError + +from litellm._logging import verbose_logger +from litellm.litellm_core_utils.core_helpers import drops_unsupported_params +from litellm.types.llms.anthropic import ( + ANTHROPIC_BETA_HEADER_VALUES, + ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER, + ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER, + ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER, + ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER, +) + +ANTHROPIC_MID_CONVERSATION_SYSTEM_CLEAR_AT_BETA_HEADER: Final = "mid-conversation-system-clear-at-2026-08-21" +ANTHROPIC_INLINE_TOOLS_BETA_HEADER: Final = "inline-tools-2026-09-15" + +THINKING_BLOCK_BINDING: Final = "thinking.block_binding" +THINKING_DISPLAY_UPDATES: Final = "thinking.display=updates" +SYSTEM_CLEAR_AT: Final = "messages[].system.clear_at" +SYSTEM_OUTPUT_CONFIG: Final = "messages[].system.output_config" +SYSTEM_TOOL_CHANGES: Final = "messages[].system.content[].tool_addition|tool_removal" +SYSTEM_MESSAGE_EMPTIED: Final = "messages[].system (emptied by the strip)" + +_TOOL_CHANGE_BLOCK_TYPES: Final = frozenset({"tool_addition", "tool_removal"}) + +BETA_GATED_FIELDS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType( + { + ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER: (THINKING_BLOCK_BINDING,), + ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER: (THINKING_DISPLAY_UPDATES,), + ANTHROPIC_MID_CONVERSATION_SYSTEM_CLEAR_AT_BETA_HEADER: (SYSTEM_CLEAR_AT,), + ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER: (SYSTEM_OUTPUT_CONFIG,), + ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value: (SYSTEM_OUTPUT_CONFIG,), + ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER: (SYSTEM_TOOL_CHANGES,), + ANTHROPIC_INLINE_TOOLS_BETA_HEADER: (SYSTEM_TOOL_CHANGES,), + } +) + + +def _betas_unlocking(field: str) -> frozenset[str]: + return frozenset(beta for beta, fields in BETA_GATED_FIELDS.items() if field in fields) + + +_FIELD_BETAS: Final[Mapping[str, frozenset[str]]] = MappingProxyType( + { + field: _betas_unlocking(field) for fields in BETA_GATED_FIELDS.values() for field in fields + } # comprehension-ok: flattening the beta-to-fields map into a field-to-betas index +) + + +def field_is_gated(field: str, active_betas: Set[str]) -> bool: + """True when no active beta unlocks ``field``, so the field must be stripped.""" + return _FIELD_BETAS[field].isdisjoint(active_betas) + + +_MAPPING_ADAPTER: Final = TypeAdapter(Mapping[str, object]) +_SEQUENCE_ADAPTER: Final = TypeAdapter(tuple[object, ...]) +_HEADERS_ADAPTER: Final = TypeAdapter(Mapping[str, str]) + + +def _as_mapping(value: object) -> Mapping[str, object] | None: + try: + return _MAPPING_ADAPTER.validate_python(value, strict=True) + except ValidationError: + return None + + +def _as_sequence(value: object) -> tuple[object, ...] | None: + try: + return _SEQUENCE_ADAPTER.validate_python(value) + except ValidationError: + return None + + +def _is_tool_change_block(block: object) -> bool: + mapping: Final = _as_mapping(block) + block_type: Final = mapping.get("type") if mapping is not None else None + return isinstance(block_type, str) and block_type in _TOOL_CHANGE_BLOCK_TYPES + + +def _header_beta_values(headers: Mapping[str, str]) -> frozenset[str]: + joined: Final = ",".join(value for key, value in headers.items() if key.lower() == "anthropic-beta") + return frozenset(piece.strip() for piece in joined.split(",") if piece.strip()) + + +def _validated_headers(headers: object) -> Mapping[str, str]: + try: + return _HEADERS_ADAPTER.validate_python(headers) + except ValidationError: + return MappingProxyType({}) + + +def active_beta_values(headers: object, body_betas: object = ()) -> frozenset[str]: + """The ``anthropic-beta`` values a request will send: the comma-joined header (key matched + case-insensitively) plus the body's ``anthropic_beta`` list on legs that carry betas in the body.""" + body_values: Final = _as_sequence(body_betas) or () + body_betas_as_strings: Final = frozenset(beta for beta in body_values if isinstance(beta, str)) + return _header_beta_values(_validated_headers(headers)) | body_betas_as_strings + + +@dataclass(frozen=True, slots=True) +class StrippedThinking: + thinking: object + stripped: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class StrippedMessages: + messages: Sequence[object] + stripped: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class StrippedRequest: + request: Mapping[str, object] + stripped: tuple[str, ...] + + +def strip_beta_gated_thinking(thinking: object, active_betas: Set[str], *, drop_params: object) -> StrippedThinking: + """Drop ``thinking`` sub-fields whose beta is absent. Returns the same object when nothing is gated.""" + mapping: Final = _as_mapping(thinking) + if mapping is None or not drops_unsupported_params(drop_params): + return StrippedThinking(thinking=thinking, stripped=()) + binding_gated: Final = "block_binding" in mapping and field_is_gated(THINKING_BLOCK_BINDING, active_betas) + display_gated: Final = mapping.get("display") == "updates" and field_is_gated( + THINKING_DISPLAY_UPDATES, active_betas + ) + if not binding_gated and not display_gated: + return StrippedThinking(thinking=thinking, stripped=()) + gated_keys: Final = frozenset( + {*(("block_binding",) if binding_gated else ()), *(("display",) if display_gated else ())} + ) + stripped: Final = ( + *((THINKING_BLOCK_BINDING,) if binding_gated else ()), + *((THINKING_DISPLAY_UPDATES,) if display_gated else ()), + ) + return StrippedThinking( + thinking={key: value for key, value in mapping.items() if key not in gated_keys}, + stripped=stripped, + ) + + +def _strip_beta_gated_system_message(message: Mapping[str, object], active_betas: Set[str]) -> StrippedMessages: + """One mid-conversation ``role: system`` message. ``messages`` holds the rewritten message, or nothing + when the strip left it with neither content nor ``output_config``, since such a system turn is itself a 400.""" + clear_at_gated: Final = "clear_at" in message and field_is_gated(SYSTEM_CLEAR_AT, active_betas) + output_config_gated: Final = "output_config" in message and field_is_gated(SYSTEM_OUTPUT_CONFIG, active_betas) + content: Final = message.get("content") + blocks: Final = _as_sequence(content) if not isinstance(content, str) else None + tool_changes_gated: Final = ( + blocks is not None + and any(_is_tool_change_block(block) for block in blocks) + and field_is_gated(SYSTEM_TOOL_CHANGES, active_betas) + ) + if not clear_at_gated and not output_config_gated and not tool_changes_gated: + return StrippedMessages(messages=(message,), stripped=()) + kept_content: Final[object] = ( + [block for block in blocks if not _is_tool_change_block(block)] + if tool_changes_gated and blocks is not None + else content + ) + gated_keys: Final = frozenset( + {*(("clear_at",) if clear_at_gated else ()), *(("output_config",) if output_config_gated else ())} + ) + rewritten: Final = { + key: (kept_content if key == "content" else value) for key, value in message.items() if key not in gated_keys + } + stripped: Final = ( + *((SYSTEM_CLEAR_AT,) if clear_at_gated else ()), + *((SYSTEM_OUTPUT_CONFIG,) if output_config_gated else ()), + *((SYSTEM_TOOL_CHANGES,) if tool_changes_gated else ()), + ) + if kept_content in (None, "", []) and "output_config" not in rewritten: + return StrippedMessages(messages=(), stripped=(*stripped, SYSTEM_MESSAGE_EMPTIED)) + return StrippedMessages(messages=(rewritten,), stripped=stripped) + + +def _strip_beta_gated_message(message: object, active_betas: Set[str]) -> StrippedMessages: + mapping: Final = _as_mapping(message) + if mapping is None or mapping.get("role") != "system": + return StrippedMessages(messages=(message,), stripped=()) + return _strip_beta_gated_system_message(mapping, active_betas) + + +def _ends_on_assistant(messages: Sequence[object]) -> bool: + last: Final = _as_mapping(messages[-1]) if messages else None + return last is not None and last.get("role") == "assistant" + + +def strip_beta_gated_system_messages( + messages: Sequence[object], active_betas: Set[str], *, drop_params: object +) -> StrippedMessages: + """Drop the gated fields of every mid-conversation ``role: system`` message. Returns the same + sequence when nothing is gated, or when dropping an emptied system turn would leave the conversation + ending on an assistant turn, which the model would read as a prefill to continue.""" + if not drops_unsupported_params(drop_params): + return StrippedMessages(messages=messages, stripped=()) + per_message: Final = tuple(_strip_beta_gated_message(message, active_betas) for message in messages) + stripped: Final = tuple( + name for result in per_message for name in result.stripped + ) # comprehension-ok: flattening per-message strip reports + if not stripped: + return StrippedMessages(messages=messages, stripped=()) + kept: Final = [ + message for result in per_message for message in result.messages + ] # comprehension-ok: flattening per-message results + if _ends_on_assistant(kept) and not _ends_on_assistant(messages): + return StrippedMessages(messages=messages, stripped=()) + return StrippedMessages(messages=kept, stripped=stripped) + + +def strip_beta_gated_request_fields( + request: Mapping[str, object], active_betas: Set[str], *, drop_params: object +) -> StrippedRequest: + """Apply both strips to an Anthropic ``/v1/messages`` shaped body. Returns the same mapping when + nothing is gated, otherwise a shallow copy with ``thinking`` and ``messages`` replaced.""" + thinking: Final = strip_beta_gated_thinking(request.get("thinking"), active_betas, drop_params=drop_params) + messages: Final = request.get("messages") + message_sequence: Final = _as_sequence(messages) if not isinstance(messages, str) else None + system_messages: Final = ( + strip_beta_gated_system_messages(message_sequence, active_betas, drop_params=drop_params) + if message_sequence is not None + else StrippedMessages(messages=(), stripped=()) + ) + stripped: Final = (*thinking.stripped, *system_messages.stripped) + if not stripped: + return StrippedRequest(request=request, stripped=()) + rewritten: Final = { + **request, + **({"thinking": thinking.thinking} if thinking.stripped else {}), + **({"messages": system_messages.messages} if system_messages.stripped else {}), + } + return StrippedRequest(request=rewritten, stripped=stripped) + + +@dataclass(frozen=True, slots=True) +class StrippedNativeRequest: + thinking: object + thinking_stripped: bool + messages: Sequence[object] + messages_stripped: bool + stripped: tuple[str, ...] + + +def unstripped_native_request(optional_params: object, messages: object) -> StrippedNativeRequest: + """The identity result for legs that strip later, once their final ``anthropic_beta`` list exists.""" + params: Final = _as_mapping(optional_params) or MappingProxyType({}) + message_sequence: Final = _as_sequence(messages) if not isinstance(messages, str) else None + return StrippedNativeRequest( + thinking=params.get("thinking"), + thinking_stripped=False, + messages=message_sequence if message_sequence is not None else (), + messages_stripped=False, + stripped=(), + ) + + +def strip_beta_gated_native_request( + headers: object, optional_params: object, messages: object, *, body_betas: object = (), drop_params: object +) -> StrippedNativeRequest: + """The ``/v1/messages`` seam, fed the final (already filtered) headers, the final body ``anthropic_beta`` + list on legs that carry betas in the body, plus the body parts it may rewrite.""" + if not drops_unsupported_params(drop_params): + return unstripped_native_request(optional_params, messages) + active_betas: Final = active_beta_values(headers, body_betas) + params: Final = _as_mapping(optional_params) or MappingProxyType({}) + thinking: Final = strip_beta_gated_thinking(params.get("thinking"), active_betas, drop_params=drop_params) + message_sequence: Final = _as_sequence(messages) if not isinstance(messages, str) else None + system_messages: Final = ( + strip_beta_gated_system_messages(message_sequence, active_betas, drop_params=drop_params) + if message_sequence is not None + else StrippedMessages(messages=(), stripped=()) + ) + return StrippedNativeRequest( + thinking=thinking.thinking, + thinking_stripped=bool(thinking.stripped), + messages=system_messages.messages, + messages_stripped=bool(system_messages.stripped), + stripped=(*thinking.stripped, *system_messages.stripped), + ) + + +def log_beta_gated_strips(stripped: Sequence[str], model: str | None, provider: str) -> None: + if not stripped: + return + described: Final = ", ".join( + f"{field} (unlocked by anthropic-beta {' or '.join(sorted(_FIELD_BETAS[field]))})" + if field in _FIELD_BETAS + else field + for field in sorted(set(stripped)) + ) + verbose_logger.warning( + "Dropped %s for model=%s provider=%s because drop_params is on and the provider would reject them on " + "this request, either since this provider leg does not send that anthropic-beta (see " + "anthropic_beta_headers_config.json) or since the model turns mid-conversation system messages into user " + "messages.", + described, + model, + provider, + ) + + +def strip_beta_gated_request_body( + request: Mapping[str, object], headers: object, *, drop_params: object, model: str | None, provider: str +) -> Mapping[str, object]: + gated: Final = strip_beta_gated_request_fields( + request, active_beta_values(headers, request.get("anthropic_beta") or ()), drop_params=drop_params + ) + log_beta_gated_strips(gated.stripped, model=model, provider=provider) + return gated.request diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index d68de6c9d4d..cee5ce15469 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -248,6 +248,7 @@ "text_editor_20241022": null, "text_editor_20250124": null, "thinking-binding-controls-2026-08-01": "thinking-binding-controls-2026-08-01", + "thinking-display-updates-2026-08-18": "thinking-display-updates-2026-08-18", "token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19", "web-fetch-2025-09-10": null, "web-search-2025-03-05": "web-search-2025-03-05" diff --git a/litellm/anthropic_beta_headers_manager.py b/litellm/anthropic_beta_headers_manager.py index fec1d8e4465..07730a0fbf8 100644 --- a/litellm/anthropic_beta_headers_manager.py +++ b/litellm/anthropic_beta_headers_manager.py @@ -241,6 +241,13 @@ def get_provider_name(provider: str) -> str: return aliases.get(provider, provider) +def has_beta_headers_config(provider: str) -> bool: + config: Final = _bundled_beta_headers_config() + aliases: Final = config.get("provider_aliases", {}) + canonical_provider: Final = (aliases.get(provider) if isinstance(aliases, Mapping) else None) or provider + return canonical_provider in config + + def filter_and_transform_beta_headers( beta_headers: list[str], provider: str, @@ -397,7 +404,7 @@ def update_request_with_filtered_beta( headers: dict, request_data: dict, provider: str, -) -> tuple[dict, dict]: +) -> tuple[dict[str, str], dict[str, object]]: """ Update both headers and request body beta fields based on provider support. Modifies both dicts in place and returns them. diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 8a82ed98991..e1436473426 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -17,6 +17,7 @@ import litellm import litellm.litellm_core_utils import litellm.types import litellm.types.utils +from litellm.anthropic_beta_gated_fields import strip_beta_gated_request_body from litellm.anthropic_beta_headers_manager import ( update_request_with_filtered_beta, ) @@ -506,11 +507,20 @@ class AnthropicChatCompletion(BaseLLM): The pair stays mutable because the streaming path rewrites it in place (`data["stream"] = True`) before sending. """ - request_headers, data = update_request_with_filtered_beta( + request_headers, filtered_data = update_request_with_filtered_beta( headers=headers, request_data=request_data, provider=custom_llm_provider, ) + data: Final = dict( + strip_beta_gated_request_body( + filtered_data, + request_headers, + drop_params=litellm_params.get("drop_params"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # litellm_params is the handler's bare dict + model=model, + provider=custom_llm_provider, + ) + ) logging_obj.pre_call( input=messages, api_key=api_key, diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index e591f3a3fde..3b7fcdea0e4 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -51,6 +51,7 @@ from litellm.types.llms.anthropic import ( ANTHROPIC_OAUTH_BETA_HEADER, ANTHROPIC_OAUTH_TOKEN_PREFIX, ANTHROPIC_SERVER_TOOL_USE_ID_PREFIX, + ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER, ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER, ANTHROPIC_TOOL_SEARCH_TOOL_TYPES, AllAnthropicToolsValues, @@ -600,6 +601,20 @@ class AnthropicModelInfo(BaseLLMModelInfo): return False return thinking.get("type") in ("adaptive", "enabled") and thinking.get("display") == "updates" + def thinking_betas(self, thinking: AnthropicThinkingParam | None) -> tuple[str, ...]: + return ( + *( + (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) + if self.is_thinking_display_updates_used(thinking) + else () + ), + *( + (ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER,) + if isinstance(thinking, dict) and "block_binding" in thinking + else () + ), + ) + def is_mid_conversation_tool_change_used(self, messages: Sequence[object]) -> bool: for message in messages: if message_field(message, "role") != "system": @@ -1180,7 +1195,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): *, custom_llm_provider: str, is_mid_conversation_output_config_used: bool = False, - is_thinking_display_updates_used: bool = False, + thinking: AnthropicThinkingParam | None = None, is_mid_conversation_tool_change_used: bool = False, ) -> list[str]: """ @@ -1217,13 +1232,10 @@ class AnthropicModelInfo(BaseLLMModelInfo): if is_mid_conversation_output_config_used: betas.append(ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER) - thinking_display_betas: Final = ( - (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else () - ) tool_change_betas: Final = ( (ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if is_mid_conversation_tool_change_used else () ) - return list(set(betas).union(thinking_display_betas, tool_change_betas)) + return list(set(betas).union(self.thinking_betas(thinking), tool_change_betas)) @staticmethod def _make_api_key_auth_header( @@ -1285,7 +1297,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): api_base: str | None = None, use_bearer_for_custom_base: bool = False, is_mid_conversation_output_config_used: bool = False, - is_thinking_display_updates_used: bool = False, + thinking: AnthropicThinkingParam | None = None, is_mid_conversation_tool_change_used: bool = False, wif_minted: bool = False, ) -> dict: @@ -1346,7 +1358,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): betas.update(user_anthropic_beta_headers) all_betas: Final = betas.union( - (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else (), + self.thinking_betas(thinking), (ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if is_mid_conversation_tool_change_used else (), ) @@ -1433,7 +1445,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): auth_token=auth_token, file_id_used=file_id_used, is_mid_conversation_output_config_used=is_mid_conversation_output_config_used, - is_thinking_display_updates_used=self.is_thinking_display_updates_used(optional_params.get("thinking")), + thinking=optional_params.get("thinking"), is_mid_conversation_tool_change_used=self.is_mid_conversation_tool_change_used(messages), web_search_tool_used=web_search_tool_used, is_vertex_request=optional_params.get("is_vertex_request", False), diff --git a/litellm/llms/anthropic/messages/native/transformation.py b/litellm/llms/anthropic/messages/native/transformation.py index 75b0329315f..c086618e829 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -2,10 +2,21 @@ from collections.abc import AsyncIterator, Mapping, Sequence from typing import ClassVar, Final import httpx +from pydantic import TypeAdapter import litellm from litellm._logging import verbose_logger -from litellm.anthropic_beta_headers_manager import get_unsupported_headers, is_beta_header_supported_by_bundled_config +from litellm.anthropic_beta_gated_fields import ( + log_beta_gated_strips, + strip_beta_gated_native_request, + strip_beta_gated_system_messages, + unstripped_native_request, +) +from litellm.anthropic_beta_headers_manager import ( + get_unsupported_headers, + has_beta_headers_config, + is_beta_header_supported_by_bundled_config, +) from litellm.constants import ANTHROPIC_BILLING_METADATA_PREFIX from litellm.exceptions import AuthenticationError from litellm.litellm_core_utils.core_helpers import drops_unsupported_params @@ -20,7 +31,6 @@ from litellm.types.llms.anthropic import ( ANTHROPIC_ADVISOR_TOOL_TYPE, ANTHROPIC_BETA_HEADER_VALUES, ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER, - ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER, AnthropicMessagesRequest, ) from litellm.types.llms.anthropic_messages.anthropic_response import ( @@ -51,6 +61,8 @@ DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01" _CALLER_CREDENTIAL_HEADERS: Final = frozenset({"x-api-key", "authorization"}) +_MESSAGE_MAPPINGS: Final = TypeAdapter(tuple[Mapping[str, object], ...]) + def _carries_caller_credential(headers: Mapping[str, str]) -> bool: """Whether the caller sent their own Anthropic credential, in which case this passthrough @@ -85,6 +97,9 @@ def _ensure_stop_sequence(response: AnthropicMessagesResponse) -> AnthropicMessa return response +_MESSAGES_ADAPTER: Final = TypeAdapter(list[dict[str, object]]) + + class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): _workload_identity_eligible: ClassVar[bool] = True @@ -229,7 +244,18 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): else: return system_param - def _normalize_system_role_messages(self, anthropic_messages_request: dict, model: str) -> None: + def _strip_beta_gated_system_turns( + self, messages: Sequence[Mapping[str, object]], model: str, drop_params: object + ) -> Sequence[Mapping[str, object]]: + if not has_beta_headers_config(self._resolved_provider): + return messages + gated: Final = strip_beta_gated_system_messages(messages, frozenset(), drop_params=drop_params) + log_beta_gated_strips(gated.stripped, model=model, provider=self._resolved_provider) + return _MESSAGE_MAPPINGS.validate_python(gated.messages) if gated.stripped else messages + + def _normalize_system_role_messages( + self, anthropic_messages_request: dict, model: str, drop_params: object + ) -> None: """Normalize ``role: "system"`` entries in ``messages`` per the Anthropic ``/v1/messages`` contract, which the first-party API, Bedrock Invoke, Vertex, and Azure Foundry all enforce identically. @@ -277,7 +303,15 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): custom_llm_provider=self.custom_llm_provider, key="supports_mid_conversation_system", ) - else list(convert_mid_conversation_system_turns(messages[leading_count:])) + else list( + convert_mid_conversation_system_turns( + self._strip_beta_gated_system_turns( + messages[leading_count:], # pyright: ignore[reportUnknownArgumentType] # messages comes from the bare request dict + model=model, + drop_params=drop_params, + ) + ) + ) ) if hoisted or remaining != messages: anthropic_messages_request["messages"] = remaining @@ -336,7 +370,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): model=model, ), ) - return self._finalize_messages_headers(headers, optional_params, messages), api_base + return self.finalize_messages_headers(headers, optional_params, messages), api_base async def avalidate_anthropic_messages_environment( self, @@ -376,7 +410,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): model=model, ), ) - return self._finalize_messages_headers(oauth_headers, optional_params, messages), api_base + return self.finalize_messages_headers(oauth_headers, optional_params, messages), api_base def _require_auth_header(self, auth_header: Mapping[str, str] | None, model: str) -> Mapping[str, str]: if auth_header is None: @@ -410,7 +444,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return config_allows_workload_identity(self) - def _finalize_messages_headers( + def finalize_messages_headers( self, headers: dict, # mutable-ok: out-param optional_params: dict, # mutable-ok: out-param @@ -619,6 +653,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if thinking_enabled or effort_enabled: optional_params.pop("temperature", None) + def strips_beta_gated_fields_from_final_headers(self) -> bool: + """False on legs whose final ``anthropic_beta`` list is only known after this transform (Bedrock Invoke), + which strip beta-gated fields themselves once that list exists, and on providers with no row in + ``anthropic_beta_headers_config.json``, whose filter drops every beta and so cannot say which fields fail.""" + return has_beta_headers_config(self._resolved_provider) + def transform_anthropic_messages_request( self, model: str, @@ -720,18 +760,37 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if not _has_advisor: messages = strip_advisor_blocks_from_messages(messages) + gated: Final = ( + strip_beta_gated_native_request( + headers, # pyright: ignore[reportUnknownArgumentType] # the base signature types this as a bare dict + anthropic_messages_optional_request_params, # pyright: ignore[reportUnknownArgumentType] # the base signature types this as a bare dict + messages, # pyright: ignore[reportUnknownArgumentType] # the base signature types this as a bare list + drop_params=getattr(litellm_params, "drop_params", None), + ) + if self.strips_beta_gated_fields_from_final_headers() + else unstripped_native_request(anthropic_messages_optional_request_params, messages) # pyright: ignore[reportUnknownArgumentType] # the base signature types these as bare dicts + ) + log_beta_gated_strips(gated.stripped, model=model, provider=self._resolved_provider) anthropic_messages_request: Final[AnthropicMessagesRequest] = AnthropicMessagesRequest( messages=strip_encrypted_reasoning_blocks_from_anthropic_messages( - drop_non_string_tool_result_texts(messages, getattr(litellm_params, "drop_params", None)) + drop_non_string_tool_result_texts( + _MESSAGES_ADAPTER.validate_python(gated.messages) if gated.messages_stripped else messages, + getattr(litellm_params, "drop_params", None), + ) ), max_tokens=max_tokens, model=model, **anthropic_messages_optional_request_params, ) + request: Final = ( + {**dict(anthropic_messages_request), "thinking": gated.thinking} + if gated.thinking_stripped + else dict(anthropic_messages_request) # pyright: ignore[reportUnknownVariableType] # built from the base signature's bare dict + ) if not isinstance(tools, list): - return dict(anthropic_messages_request) + return request return { - **anthropic_messages_request, + **request, "tools": self.tools_for_upstream( OBJECT_LIST_ADAPTER.validate_python(tools), model, litellm_params.drop_params ), @@ -866,17 +925,13 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if AnthropicModelInfo().is_tool_search_used(tools): beta_values.add(get_tool_search_beta_header(custom_llm_provider)) - thinking_display_betas: Final = ( - (ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) - if AnthropicModelInfo().is_thinking_display_updates_used(optional_params.get("thinking")) - else () - ) + thinking_betas: Final = AnthropicModelInfo().thinking_betas(optional_params.get("thinking")) tool_change_betas: Final = ( (ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if AnthropicModelInfo().is_mid_conversation_tool_change_used(messages) else () ) - all_beta_values: Final = beta_values.union(thinking_display_betas, tool_change_betas) + all_beta_values: Final = beta_values.union(thinking_betas, tool_change_betas) dangerous_tool_use: Final = ANTHROPIC_BETA_HEADER_VALUES.DANGEROUS_TOOL_USE_2026_09_03.value forwarded_beta_values: Final = ( # pyright: ignore[reportUnknownVariableType] # derived from the bare beta set all_beta_values - {dangerous_tool_use} diff --git a/litellm/llms/anthropic/prompt_cache_prediction.py b/litellm/llms/anthropic/prompt_cache_prediction.py index 17c178d169e..27e003ce8b6 100644 --- a/litellm/llms/anthropic/prompt_cache_prediction.py +++ b/litellm/llms/anthropic/prompt_cache_prediction.py @@ -661,12 +661,13 @@ def prepare_native_baseline_body(request: Mapping[str, object], model: str) -> M TypeAdapter(tuple[str, ...]).validate_python(owned.get("additional_drop_params") or ()), dict(options), ) - body: Final = AnthropicMessagesConfig().transform_anthropic_messages_request( + config: Final = AnthropicMessagesConfig() + body: Final = config.transform_anthropic_messages_request( model=resolved_model, messages=strip_provider_specific_fields_from_anthropic_messages(messages), anthropic_messages_optional_request_params=filtered, litellm_params=GenericLiteLLMParams(), - headers={}, + headers=config.finalize_messages_headers({}, dict(filtered), list(messages)), # pyright: ignore[reportUnknownMemberType] # the shared helper takes and returns bare dicts ) return MappingProxyType(_JSON_OBJECT.validate_python(body)) except Exception: # noqa: BLE001 # an unsupported hypothetical request is unavailable, never an inference failure diff --git a/litellm/llms/azure_ai/anthropic/handler.py b/litellm/llms/azure_ai/anthropic/handler.py index ca4dad5ee3e..1a2003e6262 100644 --- a/litellm/llms/azure_ai/anthropic/handler.py +++ b/litellm/llms/azure_ai/anthropic/handler.py @@ -4,11 +4,12 @@ Azure Anthropic handler - reuses AnthropicChatCompletion logic with Azure authen import copy import json -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Final import httpx +from litellm.anthropic_beta_gated_fields import strip_beta_gated_request_body from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.anthropic.chat.handler import AnthropicChatCompletion from litellm.llms.anthropic.chat.transformation import JsonModeRequest @@ -75,13 +76,22 @@ class AzureAnthropicChatCompletion(AnthropicChatCompletion): litellm_params=litellm_params, ) - data: Final = config.transform_request( + request_body: Final[Mapping[str, object]] = config.transform_request( model=model, messages=messages, optional_params=optional_params, litellm_params=litellm_params, headers=headers, ) + data: Final = dict( + strip_beta_gated_request_body( + request_body, + headers, # pyright: ignore[reportUnknownArgumentType] # validate_environment returns a bare dict + drop_params=litellm_params.get("drop_params"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # litellm_params is the handler's bare dict + model=model, + provider=custom_llm_provider, + ) + ) ## LOGGING logging_obj.pre_call( diff --git a/litellm/llms/azure_ai/anthropic/messages_transformation.py b/litellm/llms/azure_ai/anthropic/messages_transformation.py index 6b2da96de23..1f9ded827b6 100644 --- a/litellm/llms/azure_ai/anthropic/messages_transformation.py +++ b/litellm/llms/azure_ai/anthropic/messages_transformation.py @@ -145,6 +145,8 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig): litellm_params=litellm_params, headers=headers, ) - self._normalize_system_role_messages(anthropic_messages_request, model=model) + self._normalize_system_role_messages( + anthropic_messages_request, model=model, drop_params=getattr(litellm_params, "drop_params", None) + ) self._remove_scope_from_cache_control(anthropic_messages_request) return anthropic_messages_request diff --git a/litellm/llms/azure_ai/anthropic/transformation.py b/litellm/llms/azure_ai/anthropic/transformation.py index c75213c3e2c..6516827359d 100644 --- a/litellm/llms/azure_ai/anthropic/transformation.py +++ b/litellm/llms/azure_ai/anthropic/transformation.py @@ -107,9 +107,11 @@ class AzureAnthropicConfig(AnthropicConfig): is_vertex_request=optional_params.get("is_vertex_request", False), user_anthropic_beta_headers=user_anthropic_beta_headers, mcp_server_used=mcp_server_used, + thinking=optional_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # optional_params is the base config's bare dict ) # Merge headers - Azure auth (api-key or Authorization) takes precedence - headers = {**anthropic_headers, **headers} + merged_beta_header: Final = {k: v for k, v in anthropic_headers.items() if k == "anthropic-beta"} + headers = {**anthropic_headers, **headers, **merged_beta_header} # Ensure anthropic-version header is set if "anthropic-version" not in headers: diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 05440d70017..443e722168d 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -17,6 +17,8 @@ from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import verbose_logger +from litellm.anthropic_beta_gated_fields import active_beta_values, log_beta_gated_strips, strip_beta_gated_thinking +from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers from litellm.constants import ( BEDROCK_MIN_THINKING_BUDGET_TOKENS, RESPONSE_FORMAT_TOOL_NAME, @@ -1825,7 +1827,16 @@ class AmazonConverseConfig(BaseConfig): filtered_tools.append(tool) base_model: Final = BedrockModelInfo.get_base_model(model) - client_beta_list: Final = get_anthropic_beta_from_headers(headers or {}) + header_beta_list: Final = get_anthropic_beta_from_headers(headers or {}) + client_beta_list: Final = header_beta_list or filter_and_transform_beta_headers( + sorted( + active_beta_values( + {}, + additional_request_params.get("anthropic_beta"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # additional_request_params is a bare dict + ) + ), + provider="bedrock_converse", + ) eager_beta: Final = ( (ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER,) if base_model.startswith("anthropic") @@ -1833,7 +1844,15 @@ class AmazonConverseConfig(BaseConfig): and ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER not in client_beta_list else () ) - anthropic_beta_list: Final = [*client_beta_list, *eager_beta] + thinking_betas: Final = AnthropicModelInfo().thinking_betas( + additional_request_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # additional_request_params is a bare dict + ) + missing_thinking_betas: Final = ( + tuple(beta for beta in thinking_betas if beta not in client_beta_list) + if base_model.startswith("anthropic") + else () + ) + anthropic_beta_list: Final = [*client_beta_list, *eager_beta, *missing_thinking_betas] # Only separate tools if computer use tools are actually present if filtered_tools and self.is_computer_use_tool_used(filtered_tools, model): @@ -1903,7 +1922,8 @@ class AmazonConverseConfig(BaseConfig): else: computer_use_header = "computer-use-2024-10-22" - anthropic_beta_list.append(computer_use_header) + if computer_use_header not in anthropic_beta_list: + anthropic_beta_list.append(computer_use_header) # Transform computer use tools to proper Bedrock format transformed_computer_tools: Final = self._transform_computer_use_tools(computer_use_tools) additional_request_params["tools"] = transformed_computer_tools @@ -2028,6 +2048,16 @@ class AmazonConverseConfig(BaseConfig): bedrock_tools, anthropic_beta_list = self._process_tools_and_beta( original_tools, model, headers, additional_request_params, litellm_params, deployment_model ) + requested_thinking: Final = additional_request_params.get("thinking") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # _prepare_request_params hands back a bare dict + body_betas: Final = additional_request_params.get("anthropic_beta") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # same bare dict + gated_thinking: Final = strip_beta_gated_thinking( + requested_thinking, # pyright: ignore[reportUnknownArgumentType] # same bare dict + active_beta_values({}, body_betas) | frozenset(anthropic_beta_list), # pyright: ignore[reportUnknownArgumentType] # _process_tools_and_beta hands back a bare list + drop_params=drop_params, + ) + if gated_thinking.stripped: + log_beta_gated_strips(gated_thinking.stripped, model=model, provider="bedrock_converse") + additional_request_params["thinking"] = gated_thinking.thinking # Append cachePoint to tools if cache_control_injection_points has tool_config cache_injection_points: Final = additional_request_params.pop("cache_control_injection_points", None) diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude_transformation.py index eb6ed5c75e3..e32df0bbdbb 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude_transformation.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, Final import httpx from pydantic import TypeAdapter +from litellm.anthropic_beta_gated_fields import strip_beta_gated_request_body from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import object_mapping from litellm.litellm_core_utils.prompt_templates.factory import ( @@ -210,7 +211,15 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): if beta_list: _anthropic_request["anthropic_beta"] = beta_list - return _anthropic_request + return dict( + strip_beta_gated_request_body( + _anthropic_request, # pyright: ignore[reportUnknownArgumentType] # built by the base signature's bare dict transform + {}, + drop_params=litellm_params.get("drop_params"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # litellm_params is the base signature's bare dict + model=model, + provider="bedrock_invoke", + ) + ) @property def uses_async_transform_request(self) -> bool: @@ -294,7 +303,6 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): programmatic_tool_calling_used: Final = self.is_programmatic_tool_calling_used(tools) input_examples_used: Final = self.is_input_examples_used(tools) is_mid_conversation_output_config_used: Final = self.is_mid_conversation_output_config_used(messages) - is_thinking_display_updates_used: Final = self.is_thinking_display_updates_used(thinking) client_beta_set: Final = set(get_anthropic_beta_from_headers(headers)) user_beta_set: Final = ( @@ -312,7 +320,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): mcp_server_used=self.is_mcp_server_used(optional_params.get("mcp_servers")), custom_llm_provider="bedrock", is_mid_conversation_output_config_used=is_mid_conversation_output_config_used, - is_thinking_display_updates_used=is_thinking_display_updates_used, + thinking=thinking, ) beta_set.update(auto_betas) diff --git a/litellm/llms/bedrock/claude_platform/transformation.py b/litellm/llms/bedrock/claude_platform/transformation.py index 0194f1c7e45..a5ecc37d394 100644 --- a/litellm/llms/bedrock/claude_platform/transformation.py +++ b/litellm/llms/bedrock/claude_platform/transformation.py @@ -72,6 +72,7 @@ class BedrockClaudePlatformConfig(BedrockClaudePlatformMixin, AnthropicConfig): ), code_execution_tool_used=self.is_code_execution_tool_used(tools=optional_params.get("tools")), container_with_skills_used=self.is_container_with_skills_used(optional_params=optional_params), + thinking=optional_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # optional_params is the base config's bare dict ) anthropic_headers["anthropic-workspace-id"] = workspace_id return {**headers, **anthropic_headers} diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py index 60ce73abd82..e5d6a168d57 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py @@ -7,6 +7,7 @@ from pydantic import TypeAdapter import litellm from litellm._logging import verbose_logger +from litellm.anthropic_beta_gated_fields import log_beta_gated_strips, strip_beta_gated_native_request from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers from litellm.constants import ( BEDROCK_MIN_THINKING_BUDGET_TOKENS, @@ -549,6 +550,9 @@ class AmazonAnthropicClaudeMessagesConfig( "edits": retained_edits, } + def strips_beta_gated_fields_from_final_headers(self) -> bool: + return False + def _get_bedrock_invoke_anthropic_beta_headers( self, model: str, @@ -584,9 +588,7 @@ class AmazonAnthropicClaudeMessagesConfig( ), custom_llm_provider="bedrock", is_mid_conversation_output_config_used=is_mid_conversation_output_config_used, - is_thinking_display_updates_used=anthropic_model_info.is_thinking_display_updates_used( - anthropic_messages_request.get("thinking") - ), + thinking=anthropic_messages_request.get("thinking"), is_mid_conversation_tool_change_used=anthropic_model_info.is_mid_conversation_tool_change_used( outgoing_messages_typed ), @@ -757,7 +759,9 @@ class AmazonAnthropicClaudeMessagesConfig( ), "display": "updates", } - self._normalize_system_role_messages(anthropic_messages_request, model=model) + self._normalize_system_role_messages( + anthropic_messages_request, model=model, drop_params=getattr(litellm_params, "drop_params", None) + ) ######################################################### ############## BEDROCK Invoke SPECIFIC TRANSFORMATION ### ######################################################### @@ -825,6 +829,20 @@ class AmazonAnthropicClaudeMessagesConfig( if filtered_betas: anthropic_messages_request["anthropic_beta"] = filtered_betas + final_request: Final = OBJECT_MAPPING_ADAPTER.validate_python(anthropic_messages_request) # pyright: ignore[reportUnknownArgumentType] # built from the base signature's bare dict + gated: Final = strip_beta_gated_native_request( + {}, + final_request, + final_request.get("messages"), + body_betas=filtered_betas, + drop_params=getattr(litellm_params, "drop_params", None), + ) + log_beta_gated_strips(gated.stripped, model=model, provider="bedrock_invoke") + if gated.thinking_stripped: + anthropic_messages_request["thinking"] = gated.thinking + if gated.messages_stripped: + anthropic_messages_request["messages"] = list(gated.messages) + outbound_tools: Final = tools_without_eager_input_streaming(anthropic_messages_request) if outbound_tools is not None: anthropic_messages_request["tools"] = outbound_tools diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index 48118c23ed7..60081c38073 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, cast, overload import httpx from pydantic import BaseModel, ConfigDict, TypeAdapter +from litellm.anthropic_beta_gated_fields import log_beta_gated_strips, strip_beta_gated_thinking from litellm.constants import RESPONSE_FORMAT_TOOL_NAME from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( handle_invalid_parallel_tool_calls, @@ -514,6 +515,14 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig): self.update_optional_params_with_thinking_tokens( non_default_params=non_default_params, optional_params=mapped_params ) + gated_thinking: Final = strip_beta_gated_thinking( + mapped_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # mapped_params is the base config's bare dict + frozenset(), + drop_params=drop_params, + ) + if gated_thinking.stripped: + log_beta_gated_strips(gated_thinking.stripped, model=model, provider="databricks") + mapped_params["thinking"] = gated_thinking.thinking return mapped_params diff --git a/litellm/llms/databricks/messages/transformation.py b/litellm/llms/databricks/messages/transformation.py index e22ad85b5f9..90b63d860d3 100644 --- a/litellm/llms/databricks/messages/transformation.py +++ b/litellm/llms/databricks/messages/transformation.py @@ -144,7 +144,7 @@ class DatabricksAnthropicMessagesConfig(DatabricksBase, AnthropicMessagesConfig) ("content-type" if key.lower() == "content-type" else key): value for key, value in {**auth_headers, **client_anthropic_headers}.items() } - finalized_headers: Final[dict[str, str]] = self._finalize_messages_headers( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the shared helper returns a bare dict # mutable-ok: becomes the outbound headers + finalized_headers: Final[dict[str, str]] = self.finalize_messages_headers( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # the shared helper returns a bare dict # mutable-ok: becomes the outbound headers headers=normalized_headers, optional_params=optional_params, messages=messages, @@ -191,6 +191,8 @@ class DatabricksAnthropicMessagesConfig(DatabricksBase, AnthropicMessagesConfig) litellm_params=litellm_params, headers=headers, ) - self._normalize_system_role_messages(anthropic_messages_request, model=model) # pyright: ignore[reportUnknownMemberType] # the shared helper takes a bare dict + self._normalize_system_role_messages( # pyright: ignore[reportUnknownMemberType] # the shared helper takes a bare dict + anthropic_messages_request, model=model, drop_params=getattr(litellm_params, "drop_params", None) + ) self._remove_scope_from_cache_control(anthropic_messages_request) # pyright: ignore[reportUnknownMemberType] # the shared helper takes a bare dict return anthropic_messages_request diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py index 1f0ee728cd1..bfa94396ea7 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py @@ -211,6 +211,12 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert if messages_carry_output_config(messages): beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.PER_TURN_CONTROL_2026_07_01.value) + beta_values.update( + anthropic_model_info.thinking_betas( + optional_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # optional_params is the base config's bare dict + ) + ) + if beta_values: request_headers["anthropic-beta"] = ",".join(beta_values) @@ -245,7 +251,9 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert headers=headers, ) - self._normalize_system_role_messages(anthropic_messages_request, model=model) + self._normalize_system_role_messages( + anthropic_messages_request, model=model, drop_params=getattr(litellm_params, "drop_params", None) + ) self._remove_scope_from_cache_control(anthropic_messages_request) diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py index 363e5596cb9..79fd9f9e8b1 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py @@ -145,6 +145,7 @@ class VertexAIAnthropicConfig(AnthropicConfig): file_id_used=self.is_file_id_used(messages), mcp_server_used=self.is_mcp_server_used(optional_params.get("mcp_servers")), custom_llm_provider="vertex_ai", + thinking=optional_params.get("thinking"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # optional_params is the base config's bare dict ) beta_set: Final = set(auto_betas) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index de83e6ea66e..50c4a2340a3 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -832,6 +832,7 @@ ANTHROPIC_EFFORT_BETA_HEADER: Final = "effort-2025-11-24" ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER: Final = "mid-conversation-output-config-2026-07-01" ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER: Final = "thinking-display-updates-2026-08-18" +ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER: Final = "thinking-binding-controls-2026-08-01" ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER: Final = "mid-conversation-tool-changes-2026-07-01" ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER: Final = "fine-grained-tool-streaming-2025-05-14" diff --git a/tests/integration/messages_endpoint/test_beta_gated_fields_wire.py b/tests/integration/messages_endpoint/test_beta_gated_fields_wire.py new file mode 100644 index 00000000000..26ad9f20897 --- /dev/null +++ b/tests/integration/messages_endpoint/test_beta_gated_fields_wire.py @@ -0,0 +1,183 @@ +import json +import re +import uuid +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import pytest +from integration._support.client import Gateway, gateway_from_environment, list_value, object_value +from integration._support.process import owned_proxy +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +pytestmark: Final = pytest.mark.shares_worker_state + +ANTHROPIC_MODEL: Final = "beta-gated-anthropic" +FOUNDRY_MODEL: Final = "beta-gated-foundry" +FOUNDRY_DROP_PARAMS_MODEL: Final = "beta-gated-foundry-drop-params" +CLEAR_AT_BETA: Final = "mid-conversation-system-clear-at-2026-08-21" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_NONCE: Final = re.compile(rb"nonce-([0-9a-f]{32})") + + +def nonce() -> str: + return f"nonce-{uuid.uuid4().hex}" + + +def claude_reply(request: Request) -> Reply: + match: Final = _NONCE.search(request.body) + assert match is not None, request.body[:200] + identity: Final = match.group(1).decode() + return Reply( + body=json.dumps( + { + "id": f"msg_{identity}", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-8", + "content": [{"type": "text", "text": f"answered {identity}"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 12, "output_tokens": 3}, + } + ).encode() + ) + + +def respond(request: Request) -> Reply: + if request.method != "POST": + return Reply(body=b'{"object":"list","data":[]}') + return claude_reply(request) + + +def deployment(name: str, model: str, api_base: str, drop_params: bool = False) -> dict[str, JsonValue]: + return { + "model_name": name, + "litellm_params": { + "model": model, + "api_base": api_base, + "api_key": f"synthetic-{name}-key", + **({"drop_params": True} if drop_params else {}), + }, + } + + +def write_config(directory: Path, wire_url: str) -> Path: + path: Final = directory / f"beta-gated-{uuid.uuid4().hex}.json" + path.write_text( + json.dumps( + { + "model_list": [ + deployment(ANTHROPIC_MODEL, "anthropic/claude-opus-4-8", f"{wire_url}/anthropic"), + deployment(FOUNDRY_MODEL, "azure_ai/claude-opus-4-8", f"{wire_url}/foundry"), + deployment( + FOUNDRY_DROP_PARAMS_MODEL, "azure_ai/claude-opus-4-8", f"{wire_url}/foundry", drop_params=True + ), + ], + "general_settings": { + "master_key": "os.environ/LITELLM_MASTER_KEY", + "database_url": "os.environ/DATABASE_URL", + }, + } + ) + ) + return path + + +@dataclass(frozen=True, slots=True) +class Rig: + gateway: Gateway + wire: Wire + + def send(self, model: str, request_nonce: str) -> tuple[int, bytes]: + response: Final = self.gateway.client.post( + "/v1/messages", + json={ + "model": model, + "max_tokens": 64, + "messages": [ + {"role": "user", "content": f"hi {request_nonce}"}, + {"role": "assistant", "content": "Hello"}, + {"role": "user", "content": "how are you"}, + {"role": "system", "content": "Answer in French", "clear_at": "next_user_message"}, + ], + }, + headers={"Authorization": f"Bearer {self.gateway.key}", "anthropic-beta": CLEAR_AT_BETA}, + timeout=120, + ) + return response.status_code, response.content + + def received(self, request_nonce: str) -> Request: + requests: Final = tuple(request for request in self.wire.drain() if request_nonce.encode() in request.body) + assert len(requests) == 1, [request.target for request in requests] + return requests[0] + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + directory: Final = tmp_path_factory.mktemp("beta-gated") + with ( + gateway_from_environment() as shared, + wire_server(respond) as wire, + owned_proxy( + shared, + directory, + {"LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS": "true"}, + config=write_config(directory, wire.url), + workers=1, + ) as gateway, + ): + yield Rig(gateway, wire) + + +def reply_text(status: int, raw: bytes) -> str: + assert status == 200, raw[:500] + return "".join(str(object_value(block)["text"]) for block in list_value(_JSON_OBJECT.validate_json(raw)["content"])) + + +def beta_header(headers: Mapping[str, str]) -> str: + return ",".join(value for key, value in headers.items() if key.lower() == "anthropic-beta") + + +def system_message(body: bytes) -> dict[str, JsonValue]: + messages: Final = list_value(_JSON_OBJECT.validate_json(body)["messages"]) + system: Final = [object_value(m) for m in messages if object_value(m).get("role") == "system"] + assert len(system) == 1, messages + return system[0] + + +def test_a_drop_params_leg_whose_row_lacks_the_beta_sends_neither_the_beta_nor_clear_at(rig: Rig) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(FOUNDRY_DROP_PARAMS_MODEL, request_nonce) + assert reply_text(status, raw) == f"answered {request_nonce.removeprefix('nonce-')}" + upstream: Final = rig.received(request_nonce) + assert CLEAR_AT_BETA not in beta_header(upstream.headers), upstream.headers + assert system_message(upstream.body) == {"role": "system", "content": "Answer in French"} + + +def test_a_leg_whose_row_lacks_the_beta_forwards_clear_at_as_sent_without_drop_params(rig: Rig) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(FOUNDRY_MODEL, request_nonce) + assert reply_text(status, raw) == f"answered {request_nonce.removeprefix('nonce-')}" + upstream: Final = rig.received(request_nonce) + assert CLEAR_AT_BETA not in beta_header(upstream.headers), upstream.headers + assert system_message(upstream.body) == { + "role": "system", + "content": "Answer in French", + "clear_at": "next_user_message", + } + + +def test_a_leg_that_carries_the_beta_forwards_clear_at_untouched(rig: Rig) -> None: + request_nonce: Final = nonce() + status, raw = rig.send(ANTHROPIC_MODEL, request_nonce) + assert reply_text(status, raw) == f"answered {request_nonce.removeprefix('nonce-')}" + upstream: Final = rig.received(request_nonce) + assert CLEAR_AT_BETA in beta_header(upstream.headers), upstream.headers + assert system_message(upstream.body) == { + "role": "system", + "content": "Answer in French", + "clear_at": "next_user_message", + } diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py index 355d407001c..5d3fc523c0c 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -537,7 +537,11 @@ def test_streamed_thinking_blocks_accumulate_in_the_openai_sdk_and_replay_withou {"type": "content_block_delta", "index": 2, "delta": {"type": "thinking_delta", "thinking": "Step 3."}}, {"type": "content_block_delta", "index": 2, "delta": {"type": "signature_delta", "signature": "sig-b"}}, {"type": "content_block_stop", "index": 2}, - {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 9}}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 9}, + }, {"type": "message_stop"}, ) @@ -558,7 +562,11 @@ def test_streamed_thinking_blocks_accumulate_in_the_openai_sdk_and_replay_withou "thinking_blocks": message["thinking_blocks"], } replayed: Final = anthropic_messages_pt( - messages=[{"role": "user", "content": "Capital of France?"}, next_turn, {"role": "user", "content": "And Spain?"}], + messages=[ + {"role": "user", "content": "Capital of France?"}, + next_turn, + {"role": "user", "content": "And Spain?"}, + ], model="claude-haiku-4-5", llm_provider="anthropic", ) @@ -3650,3 +3658,79 @@ def test_streamed_end_turn_carries_no_stop_details(): provider_fields: Final = assembled.choices[0].message.provider_specific_fields or {} assert "stop_details" not in provider_fields assert "stop_reason" not in provider_fields + + +def _anthropic_chat_request_for_bound_thinking( + extra_headers: dict[str, str] | None, drop_params: bool | None = None +) -> httpx.Request: + captured_requests: Final[list[httpx.Request]] = [] + + def respond(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response( + 200, + json={ + "id": "msg_bound", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-8", + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + thinking: Final = {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}} + client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond))) + try: + litellm.completion( + model="anthropic/claude-opus-4-8", + messages=[{"role": "user", "content": "Hello"}], + api_key="test-key", + client=client, + thinking=thinking, + extra_headers=extra_headers, + drop_params=drop_params, + ) + finally: + client.close() + assert thinking == {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}} + return captured_requests[0] + + +def _anthropic_chat_body_for_bound_thinking( + extra_headers: dict[str, str] | None, drop_params: bool | None = None +) -> dict[str, object]: + return json.loads(_anthropic_chat_request_for_bound_thinking(extra_headers, drop_params).content) + + +def test_anthropic_chat_keeps_block_binding_when_the_final_header_carries_the_binding_beta( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + body: Final = _anthropic_chat_body_for_bound_thinking( + {"anthropic-beta": "thinking-binding-controls-2026-08-01"}, drop_params=True + ) + assert body["thinking"] == {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}} + + +@pytest.mark.parametrize("drop_params", [True, False]) +def test_anthropic_chat_adds_the_binding_beta_so_block_binding_is_kept( + drop_params: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + monkeypatch.setattr(litellm, "drop_params", False) + request: Final = _anthropic_chat_request_for_bound_thinking(None, drop_params=drop_params) + assert json.loads(request.content)["thinking"] == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + } + assert "thinking-binding-controls-2026-08-01" in request.headers["anthropic-beta"].split(",") + + +def test_anthropic_chat_sends_block_binding_as_is_without_drop_params(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + monkeypatch.setattr(litellm, "drop_params", False) + body: Final = _anthropic_chat_body_for_bound_thinking(None) + assert body["thinking"] == {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}} diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py index 13729ecb6cb..1c004d8c36e 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py @@ -1,3 +1,4 @@ +import logging from collections.abc import Mapping from datetime import datetime, timezone from pathlib import Path @@ -6,9 +7,11 @@ from unittest.mock import patch import httpx import pytest +import respx from pydantic import JsonValue import litellm +from litellm import anthropic_beta_headers_manager from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.anthropic.messages.native.transformation import ( AnthropicMessagesConfig, @@ -498,3 +501,133 @@ def test_drop_params_forwards_a_non_object_message_item_as_sent_on_the_native_re assert request["messages"][1] == 7 assert request["messages"][2]["content"][0]["content"] == [{"type": "text", "text": "sunny"}] + + +BINDING_AND_DISPLAY_BETAS: Final = "thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18" + + +def test_messages_headers_add_the_binding_beta_when_thinking_carries_block_binding() -> None: + headers: Final = AnthropicMessagesConfig()._update_headers_with_anthropic_beta( + headers={}, + optional_params={"thinking": {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}}}, + messages=[], + ) + assert "thinking-binding-controls-2026-08-01" in headers["anthropic-beta"].split(",") + + +def test_messages_headers_add_no_binding_beta_without_block_binding() -> None: + headers: Final = AnthropicMessagesConfig()._update_headers_with_anthropic_beta( + headers={}, optional_params={"thinking": {"type": "adaptive"}}, messages=[] + ) + assert "thinking-binding-controls-2026-08-01" not in headers.get("anthropic-beta", "") + + +def _bound_adaptive_request(headers: dict[str, str], drop_params: bool | None = True) -> dict[str, object]: + thinking: Final = { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + optional_params: Final = {"max_tokens": 64, "thinking": thinking} + request: Final = AnthropicMessagesConfig().transform_anthropic_messages_request( + model="claude-opus-4-8", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(drop_params=drop_params), + headers=headers, + ) + assert thinking == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + }, "the caller's thinking dict must survive for a fallback leg" + return request + + +def test_messages_request_keeps_beta_gated_thinking_fields_when_the_final_header_carries_the_betas() -> None: + request: Final = _bound_adaptive_request({"anthropic-beta": BINDING_AND_DISPLAY_BETAS}) + assert request["thinking"] == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + + +def test_messages_request_drops_beta_gated_thinking_fields_the_final_header_does_not_carry( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + request: Final = _bound_adaptive_request({"anthropic-beta": "thinking-binding-controls-2026-08-01"}) + assert request["thinking"] == {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}} + assert any("thinking.display=updates" in record.getMessage() for record in caplog.records) + + +def test_messages_request_sends_beta_gated_thinking_fields_as_is_without_drop_params( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + request: Final = _bound_adaptive_request({}, drop_params=None) + assert request["thinking"] == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + + +def test_messages_request_drops_clear_at_from_a_mid_conversation_system_turn_without_its_beta() -> None: + system_turn: Final = {"role": "system", "content": "be terse", "clear_at": "end_turn"} + request: Final = AnthropicMessagesConfig().transform_anthropic_messages_request( + model="claude-opus-4-8", + messages=[{"role": "user", "content": "hi"}, system_turn, {"role": "user", "content": "go"}], + anthropic_messages_optional_request_params={"max_tokens": 64}, + litellm_params=GenericLiteLLMParams(drop_params=True), + headers={"anthropic-beta": "mid-conversation-system-2026-07-01"}, + ) + assert request["messages"][1] == {"role": "system", "content": "be terse"} + assert system_turn == {"role": "system", "content": "be terse", "clear_at": "end_turn"} + + +def test_a_provider_without_a_beta_headers_row_forwards_beta_gated_fields_under_drop_params( + local_beta_headers_config: None, +) -> None: + thinking: Final = {"type": "enabled", "budget_tokens": 1024, "display": "updates"} + request: Final = MinimaxMessagesConfig().transform_anthropic_messages_request( + model="MiniMax-M2", + messages=[ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "Hello"}, + {"role": "user", "content": "how are you"}, + {"role": "system", "content": "Answer in French", "output_config": {"effort": "low"}}, + ], + anthropic_messages_optional_request_params={"max_tokens": 64, "thinking": dict(thinking)}, + litellm_params=GenericLiteLLMParams(drop_params=True), + headers={}, + ) + assert request["thinking"] == thinking + assert request["messages"][-1]["output_config"] == {"effort": "low"} + + +def test_a_first_party_messages_request_never_fetches_the_remote_beta_headers_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", raising=False) + monkeypatch.setattr(anthropic_beta_headers_manager, "_BETA_HEADERS_CONFIG", None) + + with respx.mock(assert_all_called=False) as router: + remote_config: Final = router.get(litellm.anthropic_beta_headers_url).mock( + return_value=httpx.Response(200, json={}) + ) + request: Final = AnthropicMessagesConfig().transform_anthropic_messages_request( + model="claude-opus-4-8", + messages=[ + {"role": "user", "content": "hi"}, + {"role": "system", "content": "be terse", "clear_at": "end_turn"}, + {"role": "user", "content": "go"}, + ], + anthropic_messages_optional_request_params={"max_tokens": 64}, + litellm_params=GenericLiteLLMParams(drop_params=True), + headers={}, + ) + + assert request["messages"][1] == {"role": "system", "content": "be terse"} + assert not remote_config.called diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index d408433e59c..b37b2534b73 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -4295,7 +4295,14 @@ def test_shared_legacy_thinking_translation_carries_block_binding( @pytest.mark.usefixtures("local_model_cost_map") @pytest.mark.parametrize( - ("custom_llm_provider", "model", "requested_thinking", "rejected_keys", "expected_thinking"), + ( + "custom_llm_provider", + "model", + "requested_thinking", + "rejected_keys", + "expected_thinking", + "provider_rejects_block_binding", + ), ( ( "anthropic", @@ -4303,6 +4310,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding( {"type": "enabled", "budget_tokens": 2048}, {"not_a_thinking_key": True}, {"type": "adaptive"}, + False, ), ( "bedrock", @@ -4310,6 +4318,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding( {"type": "enabled", "budget_tokens": 2048}, {"not_a_thinking_key": True}, {"type": "adaptive"}, + False, ), ( "bedrock", @@ -4317,6 +4326,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding( {"type": "enabled", "budget_tokens": 2048}, {"not_a_thinking_key": True}, {"type": "adaptive"}, + False, ), ( "bedrock", @@ -4324,6 +4334,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding( {"type": "adaptive", "display": "updates"}, {"not_a_thinking_key": True, "budget_tokens": 2048}, {"type": "adaptive", "display": "updates"}, + False, ), ( "vertex_ai", @@ -4331,6 +4342,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding( {"type": "enabled", "budget_tokens": 2048}, {"not_a_thinking_key": True}, {"type": "adaptive"}, + False, ), ( "databricks", @@ -4338,6 +4350,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding( {"type": "enabled", "budget_tokens": 2048}, {"not_a_thinking_key": True}, {"type": "adaptive"}, + True, ), ), ids=( @@ -4356,6 +4369,7 @@ def test_chat_thinking_drops_unknown_keys_only_under_drop_params( requested_thinking: dict[str, object], rejected_keys: dict[str, object], expected_thinking: dict[str, object], + provider_rejects_block_binding: bool, drop_params_source: str | None, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -4370,7 +4384,10 @@ def test_chat_thinking_drops_unknown_keys_only_under_drop_params( ) kept_rejected_keys: Final = {} if drop_params_source else rejected_keys - assert optional_params["thinking"] == {**expected_thinking, "block_binding": block_binding, **kept_rejected_keys} + kept_block_binding: Final = ( + {} if drop_params_source and provider_rejects_block_binding else {"block_binding": block_binding} + ) + assert optional_params["thinking"] == {**expected_thinking, **kept_block_binding, **kept_rejected_keys} @pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") diff --git a/tests/unit/llms/anthropic/test_anthropic_prompt_cache_prediction.py b/tests/unit/llms/anthropic/test_anthropic_prompt_cache_prediction.py index 7a6b6534131..4d0ebef4f2d 100644 --- a/tests/unit/llms/anthropic/test_anthropic_prompt_cache_prediction.py +++ b/tests/unit/llms/anthropic/test_anthropic_prompt_cache_prediction.py @@ -13,7 +13,7 @@ import litellm from litellm.caching.dual_cache import DualCache from litellm.caching.llm_caching_handler import LLMClientCache from litellm.llms.anthropic.count_tokens import handler as count_handler -from litellm.llms.anthropic.messages.native.transformation import DEFAULT_ANTHROPIC_API_VERSION +from litellm.llms.anthropic.messages.native.transformation import DEFAULT_ANTHROPIC_API_VERSION, AnthropicMessagesConfig from litellm.llms.anthropic.prompt_cache_prediction import ( CountedPromptCachePlan, NativePredictionTarget, @@ -26,6 +26,7 @@ from litellm.llms.anthropic.prompt_cache_prediction import ( parse_cache_plan, parse_observed_cache, parse_prompt, + prepare_native_baseline_body, resolve_baseline_prediction_target, resolve_prediction_target, supported_prediction_headers, @@ -36,7 +37,7 @@ from litellm.proxy import proxy_server from litellm.proxy.hooks.prompt_cache_prediction import PromptCacheObserver from litellm.proxy.management_endpoints.prompt_cache_prediction import predict_arm from litellm.proxy.utils import InternalUsageCache -from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo +from litellm.types.router import Deployment, GenericLiteLLMParams, LiteLLM_Params, ModelInfo from litellm.types.utils import CacheCreationTokenDetails, ModelResponse, PromptTokensDetailsWrapper, Usage _MODEL: Final = "claude-sonnet-5" @@ -50,9 +51,12 @@ def _body() -> dict[str, JsonValue]: "model": _MODEL, "system": "Keep this context", "tools": [{"name": "lookup", "input_schema": {"type": "object"}}], - "messages": [{"role": "user", "content": [ - {"type": "text", "text": "A cacheable prefix", "cache_control": {"type": "ephemeral"}} - ]}], + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "A cacheable prefix", "cache_control": {"type": "ephemeral"}}], + } + ], } @@ -88,7 +92,8 @@ async def test_observer_records_only_version_supported_by_token_counter(version: "httpx_response": httpx.Response(200, request=wire), "first_api_call_start_time": datetime.fromtimestamp(1000.0), "standard_logging_object": { - "status": "success", "model_id": _DEPLOYMENT, + "status": "success", + "model_id": _DEPLOYMENT, "metadata": {"user_api_key_hash": _CALLER}, }, }, @@ -104,13 +109,16 @@ async def test_observer_records_only_version_supported_by_token_counter(version: assert await lookup(cache, other_scope, prefix, now=1010.0) is None -@pytest.mark.parametrize("headers, supported", [ - ({}, True), - ({"Anthropic-Version": DEFAULT_ANTHROPIC_API_VERSION}, True), - ({"anthropic-version": "2099-01-01"}, False), - ({"Anthropic-Beta": ""}, False), - ({"anthropic-beta": "future-feature"}, False), -]) +@pytest.mark.parametrize( + "headers, supported", + [ + ({}, True), + ({"Anthropic-Version": DEFAULT_ANTHROPIC_API_VERSION}, True), + ({"anthropic-version": "2099-01-01"}, False), + ({"Anthropic-Beta": ""}, False), + ({"anthropic-beta": "future-feature"}, False), + ], +) def test_prediction_header_eligibility(headers: Mapping[str, str], supported: bool) -> None: assert supported_prediction_headers(headers) is supported @@ -150,49 +158,89 @@ async def test_provider_count_uses_same_version_and_preserves_native_input( @pytest.mark.parametrize("source", ["static", "database"]) @pytest.mark.asyncio async def test_environment_credential_matches_native_count_and_observed_scope( - source: str, monkeypatch: pytest.MonkeyPatch, + source: str, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("LIT7658_PROVIDER_KEY", _KEY) monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache()) params: Final = { - "model": f"anthropic/{_MODEL}", "api_key": "os.environ/LIT7658_PROVIDER_KEY", + "model": f"anthropic/{_MODEL}", + "api_key": "os.environ/LIT7658_PROVIDER_KEY", "api_base": "https://api.anthropic.com", } - router: Final = litellm.Router(model_list=[{ - "model_name": "test-native", "litellm_params": dict(params), "model_info": {"id": _DEPLOYMENT}, - }] if source == "static" else [], num_retries=0) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "test-native", + "litellm_params": dict(params), + "model_info": {"id": _DEPLOYMENT}, + } + ] + if source == "static" + else [], + num_retries=0, + ) if source == "database": monkeypatch.setattr(proxy_server, "llm_router", router) - assert proxy_server.ProxyConfig()._add_deployment([SimpleNamespace( - model_id=_DEPLOYMENT, model_name="test-native", model_info={}, litellm_params=dict(params), - )]) == 1 + assert ( + proxy_server.ProxyConfig()._add_deployment( + [ + SimpleNamespace( + model_id=_DEPLOYMENT, + model_name="test-native", + model_info={}, + litellm_params=dict(params), + ) + ] + ) + == 1 + ) deployment: Final = router.get_deployment(_DEPLOYMENT) assert deployment is not None target: Final = resolve_prediction_target(deployment.litellm_params) assert isinstance(target, NativePredictionTarget) body: Final = _body() with respx.mock() as upstream: - native: Final = upstream.post("https://api.anthropic.com/v1/messages").respond(200, json={ - "id": "msg_test", "type": "message", "role": "assistant", "model": _MODEL, - "content": [{"type": "text", "text": "Hello"}], "stop_reason": "end_turn", "stop_sequence": None, - "usage": {"input_tokens": 11, "output_tokens": 1, "cache_read_input_tokens": 300}, - }) + native: Final = upstream.post("https://api.anthropic.com/v1/messages").respond( + 200, + json={ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": _MODEL, + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 11, "output_tokens": 1, "cache_read_input_tokens": 300}, + }, + ) counter: Final = upstream.post("https://api.anthropic.com/v1/messages/count_tokens").respond( - 200, json={"input_tokens": 311}, + 200, + json={"input_tokens": 311}, ) await router.aanthropic_messages( - model="test-native", max_tokens=1, **{key: value for key, value in body.items() if key != "model"}, + model="test-native", + max_tokens=1, + **{key: value for key, value in body.items() if key != "model"}, ) assert await count_prompt_tokens(target.model, target.api_key, body) == 311 assert native.call_count == counter.call_count == 1 assert native.calls.last.request.headers["x-api-key"] == counter.calls.last.request.headers["x-api-key"] == _KEY - observed: Final = parse_observed_cache(native.calls.last.request, ModelResponse( - model=_MODEL, usage=Usage( - prompt_tokens=311, completion_tokens=1, total_tokens=312, - prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=300), + observed: Final = parse_observed_cache( + native.calls.last.request, + ModelResponse( + model=_MODEL, + usage=Usage( + prompt_tokens=311, + completion_tokens=1, + total_tokens=312, + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=300), + ), ), - ), _CALLER, _DEPLOYMENT) + _CALLER, + _DEPLOYMENT, + ) assert observed is not None assert observed.scope == cache_scope(_CALLER, _DEPLOYMENT, target.api_key, target.model) @@ -200,15 +248,26 @@ async def test_environment_credential_matches_native_count_and_observed_scope( @pytest.mark.parametrize("inline_key", [None, _KEY]) @pytest.mark.asyncio async def test_named_credential_is_explicitly_unsupported_before_count( - inline_key: str | None, monkeypatch: pytest.MonkeyPatch, + inline_key: str | None, + monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(litellm, "credential_list", [CredentialItem( - credential_name="test-named", credential_info={}, credential_values={"api_key": "test-named-provider-key"}, - )]) + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="test-named", + credential_info={}, + credential_values={"api_key": "test-named-provider-key"}, + ) + ], + ) deployment: Final = Deployment( model_name="test-native", litellm_params=LiteLLM_Params( - model=f"anthropic/{_MODEL}", api_key=inline_key, litellm_credential_name="test-named", + model=f"anthropic/{_MODEL}", + api_key=inline_key, + litellm_credential_name="test-named", ), model_info=ModelInfo(id=_DEPLOYMENT), ) @@ -232,8 +291,7 @@ def _cache_plan(body: Mapping[str, JsonValue]) -> PromptCachePlan: def _text(text: str, ttl: str | None = None) -> dict[str, JsonValue]: - return {"type": "text", "text": text, - **({"cache_control": {"type": "ephemeral", "ttl": ttl}} if ttl else {})} + return {"type": "text", "text": text, **({"cache_control": {"type": "ephemeral", "ttl": ttl}} if ttl else {})} def _prompt(*blocks: dict[str, JsonValue], role: str = "user", **options: JsonValue) -> dict[str, JsonValue]: @@ -251,16 +309,19 @@ def test_public_predictor_preserves_string_message_policy(text: str, supported: def test_cache_plan_preserves_hierarchical_prefixes_and_public_policy() -> None: body: Final = _prompt( - _text("First turn", "5m"), system=[_text("Stable instructions", "1h")], - tools=[{"name": "lookup", "input_schema": {"type": "object"}, - "cache_control": {"type": "ephemeral", "ttl": "1h"}}], + _text("First turn", "5m"), + system=[_text("Stable instructions", "1h")], + tools=[ + {"name": "lookup", "input_schema": {"type": "object"}, "cache_control": {"type": "ephemeral", "ttl": "1h"}} + ], ) plan: Final = _cache_plan(body) changed: Final = _cache_plan({**body, "system": [_text("Changed instructions", "1h")]}) assert tuple(marker.ttl_seconds for marker in plan.breakpoints) == (3600, 3600, 300) assert plan.breakpoints[0].fingerprint == changed.breakpoints[0].fingerprint - assert all(left.fingerprint != right.fingerprint for left, right - in zip(plan.breakpoints[1:], changed.breakpoints[1:])) + assert all( + left.fingerprint != right.fingerprint for left, right in zip(plan.breakpoints[1:], changed.breakpoints[1:]) + ) assert plan.breakpoints[0].prefix_body == { "tools": [{"name": "lookup", "input_schema": {"type": "object"}}], "messages": [], @@ -268,51 +329,80 @@ def test_cache_plan_preserves_hierarchical_prefixes_and_public_policy() -> None: assert parse_prompt(body) is None -@pytest.mark.parametrize("kind, added, matches", [ - ("text", 19, True), ("text", 20, False), ("tool_use", 30, True), - ("tool_result", 30, True), -]) +@pytest.mark.parametrize( + "kind, added, matches", + [ + ("text", 19, True), + ("text", 20, False), + ("tool_use", 30, True), + ("tool_result", 30, True), + ], +) def test_cache_plan_lookback_counts_native_positions( - kind: str, added: int, matches: bool, + kind: str, + added: int, + matches: bool, ) -> None: previous: Final = _cache_plan(_body()) appended: Final[list[dict[str, JsonValue]]] = [ {"type": "tool_use", "id": f"tool_{index}", "name": "lookup", "input": {}} - if kind == "tool_use" else - {"type": "tool_result", "tool_use_id": f"tool_{index}", "content": "done"} - if kind == "tool_result" else - {"type": "text", "text": f"Added {index}"} + if kind == "tool_use" + else {"type": "tool_result", "tool_use_id": f"tool_{index}", "content": "done"} + if kind == "tool_result" + else {"type": "text", "text": f"Added {index}"} for index in range(added) ] - current: Final = _cache_plan({**_body(), **_prompt( - _text("A cacheable prefix"), *appended[:-1], - {**appended[-1], "cache_control": {"type": "ephemeral"}}, - )}) - assert (previous.breakpoints[0].fingerprint - in current.breakpoints[0].lookback_fingerprints) is matches + current: Final = _cache_plan( + { + **_body(), + **_prompt( + _text("A cacheable prefix"), + *appended[:-1], + {**appended[-1], "cache_control": {"type": "ephemeral"}}, + ), + } + ) + assert (previous.breakpoints[0].fingerprint in current.breakpoints[0].lookback_fingerprints) is matches -@pytest.mark.parametrize("change, same_prefix, same_content", [ - ("tool_order", False, False), ("effort", False, False), - ("standard_speed", True, True), ("ttl", False, True), -]) +@pytest.mark.parametrize( + "change, same_prefix, same_content", + [ + ("tool_order", False, False), + ("effort", False, False), + ("standard_speed", True, True), + ("ttl", False, True), + ], +) def test_cache_plan_identity_respects_settings_and_preserves_content( - change: str, same_prefix: bool, same_content: bool, + change: str, + same_prefix: bool, + same_content: bool, ) -> None: tool_input: Final[dict[str, JsonValue]] = {"a": 1, "b": 2, "cache_control": {"ttl": "user-data"}} block: Final[dict[str, JsonValue]] = { - "type": "tool_use", "id": "tool_1", "name": "lookup", "input": tool_input, + "type": "tool_use", + "id": "tool_1", + "name": "lookup", + "input": tool_input, "cache_control": {"type": "ephemeral", "ttl": "5m"}, } changed_block: Final = ( - {**block, "input": dict(reversed(tool_input.items()))} if change == "tool_order" else - {**block, "cache_control": {"type": "ephemeral", "ttl": "1h"}} if change == "ttl" else block + {**block, "input": dict(reversed(tool_input.items()))} + if change == "tool_order" + else {**block, "cache_control": {"type": "ephemeral", "ttl": "1h"}} + if change == "ttl" + else block ) before: Final = _cache_plan(_prompt(block, role="assistant", output_config={"effort": "low"})).breakpoints[0] - after: Final = _cache_plan(_prompt( - changed_block, role="assistant", output_config={"effort": "high" if change == "effort" else "low"}, - **({"speed": "standard"} if change == "standard_speed" else {}), - )).breakpoints[0] + after: Final = _cache_plan( + _prompt( + changed_block, + role="assistant", + output_config={"effort": "high" if change == "effort" else "low"}, + **({"speed": "standard"} if change == "standard_speed" else {}), + ) + ).breakpoints[0] assert (before.fingerprint == after.fingerprint) is same_prefix assert (before.fingerprint in after.lookback_fingerprints) is same_prefix assert (before.content_fingerprint == after.content_fingerprint) is same_content @@ -323,30 +413,41 @@ def test_cache_plan_identity_respects_settings_and_preserves_content( def test_cache_plan_automatic_cache_and_thinking_use_last_cacheable_block() -> None: body: Final = _prompt( - _text("A stable answer"), {"type": "thinking", "thinking": "Thinking", "signature": "signature"}, - role="assistant", thinking={"type": "adaptive"}, cache_control={"type": "ephemeral", "ttl": "1h"}, + _text("A stable answer"), + {"type": "thinking", "thinking": "Thinking", "signature": "signature"}, + role="assistant", + thinking={"type": "adaptive"}, + cache_control={"type": "ephemeral", "ttl": "1h"}, ) plan: Final = _cache_plan(body) assert len(plan.breakpoints) == 1 assert plan.breakpoints[0].ttl_seconds == 3600 assert plan.breakpoints[0].prefix_body == { "thinking": {"type": "adaptive"}, - "messages": [{"role": "assistant", "content": [ - {"type": "text", "text": "A stable answer"}, - ]}], + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "A stable answer"}, + ], + } + ], } assert parse_prompt(body) is None -@pytest.mark.parametrize("body, reason", [ - (_prompt({"type": "image"}), "unsupported_prompt_shape"), - ({**_body(), "unknown_native_setting": True}, "unsupported_prompt_shape"), - ({**_body(), "cache_control": {"type": "ephemeral", "ttl": "1h"}}, - "conflicting_cache_ttl"), - (_prompt(_text("five", "5m"), _text("hour", "1h")), "invalid_cache_ttl_order"), -]) +@pytest.mark.parametrize( + "body, reason", + [ + (_prompt({"type": "image"}), "unsupported_prompt_shape"), + ({**_body(), "unknown_native_setting": True}, "unsupported_prompt_shape"), + ({**_body(), "cache_control": {"type": "ephemeral", "ttl": "1h"}}, "conflicting_cache_ttl"), + (_prompt(_text("five", "5m"), _text("hour", "1h")), "invalid_cache_ttl_order"), + ], +) def test_cache_plan_unsupported_is_explicit( - body: Mapping[str, JsonValue], reason: str, + body: Mapping[str, JsonValue], + reason: str, ) -> None: result: Final = parse_cache_plan(body) assert isinstance(result, UnsupportedCachePlan) @@ -354,30 +455,46 @@ def test_cache_plan_unsupported_is_explicit( @pytest.mark.asyncio -@pytest.mark.parametrize("model, counts, reason", [ - (None, (100, 150, 200), None), - (None, (100, 201, 200), "inconsistent_prefix_token_count"), - (None, (151, 150, 200), "inconsistent_prefix_token_count"), - (None, (None, 150, 200), "token_count_unavailable"), - ("claude-opus-5", (100, 150, 200), None), - ("claude-sonnet-5", (100, 150, 200), None), - ("declared-cache-model", (100, 150, 200), None), - ("unknown-cache-model", (100, 150, 200), "unsupported_thinking_cache_semantics"), - ("claude-haiku-4-5", (100, 150, 200), "unsupported_thinking_cache_semantics"), - ("claude-sonnet-4-5", (100, 150, 200), "unsupported_thinking_cache_semantics"), -]) +@pytest.mark.parametrize( + "model, counts, reason", + [ + (None, (100, 150, 200), None), + (None, (100, 201, 200), "inconsistent_prefix_token_count"), + (None, (151, 150, 200), "inconsistent_prefix_token_count"), + (None, (None, 150, 200), "token_count_unavailable"), + ("claude-opus-5", (100, 150, 200), None), + ("claude-sonnet-5", (100, 150, 200), None), + ("declared-cache-model", (100, 150, 200), None), + ("unknown-cache-model", (100, 150, 200), "unsupported_thinking_cache_semantics"), + ("claude-haiku-4-5", (100, 150, 200), "unsupported_thinking_cache_semantics"), + ("claude-sonnet-4-5", (100, 150, 200), "unsupported_thinking_cache_semantics"), + ], +) async def test_cache_plan_count_conserves_total_and_rejects_unknown( - model: str | None, counts: tuple[int | None, int | None, int], reason: str | None, + model: str | None, + counts: tuple[int | None, int | None, int], + reason: str | None, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setitem(litellm.model_cost, "declared-cache-model", { - "litellm_provider": "anthropic", "mode": "chat", "supports_thinking_cache_preservation": True, - }) - plan: Final = _cache_plan(_prompt( - {"type": "thinking", "thinking": "Retained thought", "signature": "signature"} - if model else _text("first", "5m"), - _text("second", "5m"), _text("uncached"), role="assistant" if model else "user", - )) + monkeypatch.setitem( + litellm.model_cost, + "declared-cache-model", + { + "litellm_provider": "anthropic", + "mode": "chat", + "supports_thinking_cache_preservation": True, + }, + ) + plan: Final = _cache_plan( + _prompt( + {"type": "thinking", "thinking": "Retained thought", "signature": "signature"} + if model + else _text("first", "5m"), + _text("second", "5m"), + _text("uncached"), + role="assistant" if model else "user", + ) + ) async def count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: assert reason != "unsupported_thinking_cache_semantics", "Unverified thinking retention must skip counting" @@ -399,12 +516,15 @@ async def test_cache_plan_count_conserves_total_and_rejects_unknown( @pytest.mark.parametrize("section", ["system", "tools"]) @pytest.mark.parametrize("rejects_prefix", (False, True)) async def test_native_count_preserves_settings_and_requires_every_prefix( - section: str, rejects_prefix: bool, monkeypatch: pytest.MonkeyPatch, + section: str, + rejects_prefix: bool, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache()) params: Final = LiteLLM_Params( - model=f"anthropic/{_MODEL}", api_key=_KEY, + model=f"anthropic/{_MODEL}", + api_key=_KEY, api_base="https://gateway.example/v1/messages", ) target: Final = resolve_baseline_prediction_target(params) @@ -413,19 +533,38 @@ async def test_native_count_preserves_settings_and_requires_every_prefix( assert not isinstance(resolve_prediction_target(params), NativePredictionTarget) body: Final = _body() marker: Final[dict[str, JsonValue]] = {"type": "ephemeral", "ttl": "1h"} - body[section] = ([_text("A cached system", "1h")] if section == "system" else [{ - "name": "lookup", "input_schema": {"type": "object"}, "cache_control": marker, - }]) - plan: Final = _cache_plan({**body, **_prompt( - _text("A later prefix", "5m"), _text("An uncached suffix"), - thinking={"type": "adaptive"}, tool_choice={"type": "auto"}, output_config={"effort": "high"}, - )}) + body[section] = ( + [_text("A cached system", "1h")] + if section == "system" + else [ + { + "name": "lookup", + "input_schema": {"type": "object"}, + "cache_control": marker, + } + ] + ) + plan: Final = _cache_plan( + { + **body, + **_prompt( + _text("A later prefix", "5m"), + _text("An uncached suffix"), + thinking={"type": "adaptive"}, + tool_choice={"type": "auto"}, + output_config={"effort": "high"}, + ), + } + ) assert len(plan.breakpoints) == 2 assert plan.breakpoints[0].prefix_body["messages"] == [] async def count(model: str, api_key: str, body: Mapping[str, JsonValue]) -> int | None: return await count_prompt_tokens( - model, api_key, {**body, "max_tokens": 100}, api_base=target.api_base, + model, + api_key, + {**body, "max_tokens": 100}, + api_base=target.api_base, ) with respx.mock(assert_all_called=False) as upstream: @@ -434,12 +573,16 @@ async def test_native_count_preserves_settings_and_requires_every_prefix( upstream.post(endpoint, json={**body, "model": _MODEL}).respond( 400 if rejects_prefix and index == 1 else 200, json={"detail": {"error": "messages parameter is required"}} - if rejects_prefix and index == 1 else {"input_tokens": tokens}, + if rejects_prefix and index == 1 + else {"input_tokens": tokens}, + ) + for index, (body, tokens) in enumerate( + ( + (plan.full_body, 6000), + (plan.breakpoints[0].prefix_body, 5000), + (plan.breakpoints[1].prefix_body, 5800), + ) ) - for index, (body, tokens) in enumerate(( - (plan.full_body, 6000), (plan.breakpoints[0].prefix_body, 5000), - (plan.breakpoints[1].prefix_body, 5800), - )) ) unexpected: Final = upstream.post(endpoint).respond(200, json={"input_tokens": 1}) result: Final = await count_cache_plan(target.model, target.api_key, plan, count) @@ -452,3 +595,32 @@ async def test_native_count_preserves_settings_and_requires_every_prefix( assert tuple(marker.prefix_tokens for marker in result.breakpoints) == (5000, 5800) assert tuple(route.call_count for route in routes) == (1, 1, 1) assert unexpected.call_count == 0 + + +def test_native_baseline_body_matches_the_sent_body_when_drop_params_meets_an_auto_added_beta( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "drop_params", True) + thinking: Final = {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}} + messages: Final = [{"role": "user", "content": "Say hi in French"}] + predicted: Final = prepare_native_baseline_body( + {"model": "claude-opus-4-8", "messages": messages, "max_tokens": 64, "thinking": thinking}, "claude-opus-4-8" + ) + config: Final = AnthropicMessagesConfig() + headers, _ = config.validate_anthropic_messages_environment( + headers={}, + model="claude-opus-4-8", + messages=[dict(message) for message in messages], + optional_params={"max_tokens": 64, "thinking": dict(thinking)}, + litellm_params={}, + api_key="sk-ant-test", + ) + sent: Final = config.transform_anthropic_messages_request( + model="claude-opus-4-8", + messages=[dict(message) for message in messages], + anthropic_messages_optional_request_params={"max_tokens": 64, "thinking": dict(thinking)}, + litellm_params=GenericLiteLLMParams(), + headers=headers, + ) + assert predicted is not None + assert predicted["thinking"] == sent["thinking"] == thinking diff --git a/tests/unit/llms/azure_ai/claude/test_azure_anthropic_handler.py b/tests/unit/llms/azure_ai/claude/test_azure_anthropic_handler.py index 052c4c422f2..d886303da8f 100644 --- a/tests/unit/llms/azure_ai/claude/test_azure_anthropic_handler.py +++ b/tests/unit/llms/azure_ai/claude/test_azure_anthropic_handler.py @@ -6,6 +6,7 @@ sys.path.insert( ) import json +from typing import Final from unittest.mock import MagicMock, patch import pytest @@ -272,3 +273,71 @@ def test_completion_surfaces_the_status_and_headers_of_a_rejected_request(): assert rejected.value.status_code == 400 assert rejected.value.litellm_response_headers["x-request-id"] == "req-1" assert "max_tokens too large" in str(rejected.value) + + +@respx.mock +@pytest.mark.parametrize("drop_params", [True, False]) +def test_completion_adds_the_binding_beta_so_block_binding_is_kept( + drop_params: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx.post("https://example-resource.services.ai.azure.com/anthropic/v1/messages").mock( + return_value=Response( + 200, + json={ + "id": "msg_bound", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + ) + block_binding: Final = {"prefix_mismatch_behavior": "error"} + + litellm.completion( + model="azure_ai/claude-sonnet-4-6", + messages=[{"role": "user", "content": "hi"}], + api_base="https://example-resource.services.ai.azure.com/anthropic", + api_key="azure-test-key", + thinking={"type": "enabled", "budget_tokens": 1024, "block_binding": block_binding}, + drop_params=drop_params, + ) + + sent: Final = json.loads(route.calls.last.request.content) + assert sent["thinking"] == {"type": "enabled", "budget_tokens": 1024, "block_binding": block_binding} + assert "thinking-binding-controls-2026-08-01" in route.calls.last.request.headers["anthropic-beta"].split(",") + + +@respx.mock +def test_completion_sends_the_binding_beta_next_to_the_callers_own_betas(local_beta_headers_config: None) -> None: + route: Final = respx.post("https://example-resource.services.ai.azure.com/anthropic/v1/messages").mock( + return_value=Response( + 200, + json={ + "id": "msg_bound", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + ) + + litellm.completion( + model="azure_ai/claude-sonnet-4-6", + messages=[{"role": "user", "content": "hi"}], + api_base="https://example-resource.services.ai.azure.com/anthropic", + api_key="azure-test-key", + thinking={"type": "enabled", "budget_tokens": 1024, "block_binding": {"prefix_mismatch_behavior": "error"}}, + extra_headers={"anthropic-beta": "context-1m-2025-08-07"}, + ) + + sent_betas: Final = set(route.calls.last.request.headers["anthropic-beta"].split(",")) + assert {"context-1m-2025-08-07", "thinking-binding-controls-2026-08-01"} <= sent_betas diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude_transformation.py index 86c4b29eb53..d40abb4860b 100644 --- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude_transformation.py +++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude_transformation.py @@ -2008,3 +2008,25 @@ def test_invoke_uses_opt_in_native_output_is_false_when_native_output_did_not_co assert not AmazonAnthropicClaudeConfig().uses_opt_in_native_output( model, optional_params, {"model_info": model_info} ) + + +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +@pytest.mark.parametrize("drop_params", [True, False]) +def test_bedrock_chat_invoke_adds_the_binding_beta_so_block_binding_is_kept( + drop_params: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + block_binding: Final = {"prefix_mismatch_behavior": "error"} + result: Final = AmazonAnthropicClaudeConfig().transform_request( + model="us.anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "max_tokens": 2048, + "thinking": {"type": "enabled", "budget_tokens": 1024, "block_binding": block_binding}, + }, + litellm_params={"drop_params": drop_params}, + headers={}, + ) + + assert result["thinking"] == {"type": "enabled", "budget_tokens": 1024, "block_binding": block_binding} + assert "thinking-binding-controls-2026-08-01" in result["anthropic_beta"] diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 2d75ce1f22f..d82ba192b1f 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -24,9 +24,9 @@ from litellm.llms.anthropic.chat.transformation import ( NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS, NATIVE_OUTPUT_MAX_UNION_PARAMETERS, ) -from litellm.llms.bedrock.common_utils import BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig +from litellm.llms.bedrock.common_utils import BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.types.llms.bedrock import ContentBlock, ConverseTokenUsageBlock, RequestObject from litellm.types.llms.openai import AllMessageValues @@ -5355,7 +5355,6 @@ def test_recursive_output_config_format_follows_the_profile_native_key( assert ("outputConfig" in request) is forwarded - def test_sonnet_4_6_json_schema_still_uses_native_output_config(monkeypatch): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) @@ -10672,7 +10671,10 @@ def test_uses_opt_in_native_output_reads_the_base_model_of_an_application_infere _CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT: Final = { - "textFormat": {"type": "json_schema", "structure": {"jsonSchema": {"name": "capital", "schema": {"type": "object"}}}} + "textFormat": { + "type": "json_schema", + "structure": {"jsonSchema": {"name": "capital", "schema": {"type": "object"}}}, + } } _BEDROCK_SCHEMA_TYPE_ERROR: Final = "outputConfig.textFormat.structure.jsonSchema.schema must be a string" @@ -10796,3 +10798,121 @@ def test_a_deployment_with_a_batch_control_plane_endpoint_keeps_it_out_of_chat_r assert response.choices[0].message.content == "ok" assert "aws_bedrock_control_plane_endpoint" not in route.calls[0].request.content.decode() + + +def _converse_bound_thinking_request( + headers: dict[str, str] | None, body_betas: tuple[str, ...] = (), drop_params: bool = True +) -> dict[str, object]: + thinking: Final = { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + data: Final = AmazonConverseConfig()._transform_request_helper( + model="anthropic.claude-opus-4-8-v1:0", + system_content_blocks=[], + optional_params={"thinking": thinking, **({"anthropic_beta": list(body_betas)} if body_betas else {})}, + messages=[{"role": "user", "content": "hi"}], + headers=headers, + drop_params=drop_params, + ) + assert thinking == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + }, "the caller's thinking dict must survive for a fallback leg" + return data["additionalModelRequestFields"] + + +def test_converse_keeps_beta_gated_thinking_fields_the_bedrock_converse_row_carries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + fields: Final = _converse_bound_thinking_request( + {"anthropic-beta": "thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18"} + ) + assert fields["thinking"] == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + + +def test_converse_adds_the_thinking_betas_so_gated_thinking_fields_are_kept( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + fields: Final = _converse_bound_thinking_request(None) + assert fields["thinking"] == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + assert sorted(fields["anthropic_beta"]) == [ + "thinking-binding-controls-2026-08-01", + "thinking-display-updates-2026-08-18", + ] + + +def test_converse_keeps_the_callers_body_betas_next_to_the_added_thinking_betas( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + fields: Final = _converse_bound_thinking_request(None, body_betas=("context-1m-2025-08-07",)) + assert sorted(fields["anthropic_beta"]) == [ + "context-1m-2025-08-07", + "thinking-binding-controls-2026-08-01", + "thinking-display-updates-2026-08-18", + ] + + +def test_converse_sends_beta_gated_thinking_fields_as_is_without_drop_params(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + monkeypatch.setattr(litellm, "drop_params", False) + fields: Final = _converse_bound_thinking_request(None, drop_params=False) + assert fields["thinking"] == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + + +def test_converse_keeps_beta_gated_thinking_fields_the_body_anthropic_beta_carries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + fields: Final = _converse_bound_thinking_request( + None, body_betas=("thinking-binding-controls-2026-08-01", "thinking-display-updates-2026-08-18") + ) + assert fields["thinking"] == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + + +def test_converse_drops_body_betas_the_bedrock_converse_row_rejects_when_adding_thinking_betas( + local_beta_headers_config: None, +) -> None: + fields: Final = _converse_bound_thinking_request( + None, body_betas=("per-turn-control-2026-07-01", "context-1m-2025-08-07") + ) + assert sorted(fields["anthropic_beta"]) == [ + "context-1m-2025-08-07", + "thinking-binding-controls-2026-08-01", + "thinking-display-updates-2026-08-18", + ] + + +def test_converse_sends_only_the_header_betas_when_header_and_body_both_carry_betas( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") + fields: Final = _converse_bound_thinking_request( + {"anthropic-beta": "context-1m-2025-08-07"}, body_betas=("not-a-real-beta-2099",) + ) + assert sorted(fields["anthropic_beta"]) == [ + "context-1m-2025-08-07", + "thinking-binding-controls-2026-08-01", + "thinking-display-updates-2026-08-18", + ] diff --git a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py index c096e2126ae..74f6f3030cb 100644 --- a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py +++ b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py @@ -16,7 +16,7 @@ import httpx import pytest import requests_mock as requests_mock_lib import respx -from pydantic import TypeAdapter +from pydantic import BaseModel, ConfigDict, TypeAdapter import litellm from litellm.constants import ( @@ -4200,6 +4200,98 @@ def test_bedrock_messages_without_guardrail_config_sends_no_guardrail_headers() assert not any(name.startswith("X-Amzn-Bedrock-Guardrail") for name in headers) +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +def test_bedrock_messages_keeps_mid_conversation_output_config_its_auto_added_beta_gates() -> None: + """Invoke learns its body betas after the shared transform, so the gated field it supports must survive.""" + from litellm.types.router import GenericLiteLLMParams + + messages: Final = [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "Hi"}]}, + {"role": "system", "content": "Answer briefly", "output_config": {"effort": "high"}}, + {"role": "user", "content": [{"type": "text", "text": "Go on"}]}, + ] + result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( + model="anthropic.claude-opus-4-8-v1:0", + messages=messages, + anthropic_messages_optional_request_params={"max_tokens": 4096}, + litellm_params=GenericLiteLLMParams(drop_params=True), + headers={}, + ) + + assert "mid-conversation-output-config-2026-07-01" in result["anthropic_beta"] + assert {"role": "system", "content": "Answer briefly", "output_config": {"effort": "high"}} in result["messages"] + + +class _SentMessage(BaseModel): + model_config = ConfigDict(frozen=True) + role: str + content: list[dict[str, object]] + + +_SENT_MESSAGES: Final = TypeAdapter(list[_SentMessage]) + + +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +@pytest.mark.parametrize("drop_params", [True, False]) +def test_bedrock_messages_drops_tool_changes_from_a_system_turn_an_older_model_gets_as_a_user_turn( + drop_params: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + """A model without mid-conversation system support sends no tool-changes beta, so under drop_params the + converted turn must not carry the blocks that beta gates, and without it they go out as sent.""" + monkeypatch.setattr(litellm, "drop_params", False) + from litellm.types.router import GenericLiteLLMParams + + messages: Final = [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "Hi"}]}, + { + "role": "system", + "content": [ + {"type": "text", "text": "A weather tool is now available"}, + {"type": "tool_addition", "tool": {"type": "tool_reference", "name": "get_weather"}}, + ], + }, + {"role": "user", "content": [{"type": "text", "text": "Go on"}]}, + ] + result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( + model="anthropic.claude-3-7-sonnet-20250219-v1:0", + messages=messages, + anthropic_messages_optional_request_params={"max_tokens": 4096}, + litellm_params=GenericLiteLLMParams(drop_params=drop_params), + headers={}, + ) + + sent: Final = _SENT_MESSAGES.validate_python(result["messages"]) + blocks: Final = [ + block for message in sent for block in message.content + ] # comprehension-ok: flattening the sent content blocks + assert "mid-conversation-tool-changes-2026-07-01" not in result.get("anthropic_beta", []) + assert any(block["type"] == "tool_addition" for block in blocks) is not drop_params + assert {"type": "text", "text": "A weather tool is now available"} in blocks + assert all(message.role != "system" for message in sent) + + +@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") +@pytest.mark.parametrize("drop_params", (True, False)) +def test_bedrock_messages_sends_the_binding_beta_with_block_binding_whatever_drop_params(drop_params: bool) -> None: + from litellm.types.router import GenericLiteLLMParams + + result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( + model="us.anthropic.claude-sonnet-4-6", + messages=[{"role": "user", "content": "Reply with OK"}], + anthropic_messages_optional_request_params={ + "max_tokens": 2048, + "thinking": {"type": "enabled", "budget_tokens": 1024, "block_binding": BLOCK_BINDING}, + }, + litellm_params=GenericLiteLLMParams(drop_params=drop_params), + headers={}, + ) + + assert result["thinking"]["block_binding"] == BLOCK_BINDING + assert "thinking-binding-controls-2026-08-01" in result["anthropic_beta"] + + @dataclass(slots=True) class BedrockDownAnthropicUp: anthropic_bodies: tuple[dict[str, object], ...] = () diff --git a/tests/unit/llms/bedrock/test_claude_platform_provider.py b/tests/unit/llms/bedrock/test_claude_platform_provider.py index 2353a45139b..9142fe45752 100644 --- a/tests/unit/llms/bedrock/test_claude_platform_provider.py +++ b/tests/unit/llms/bedrock/test_claude_platform_provider.py @@ -207,6 +207,26 @@ def test_claude_platform_api_key_auth_sets_workspace_and_key_headers(): assert headers["anthropic-beta"] == "skills-2025-10-02" +def test_claude_platform_chat_sends_the_binding_beta_when_thinking_carries_block_binding(): + from litellm.llms.bedrock.claude_platform.transformation import ( + BedrockClaudePlatformConfig, + ) + + headers: Final = BedrockClaudePlatformConfig().validate_environment( + api_key="fake-platform-key", + headers={}, + model="claude-opus-5-5", + messages=[{"role": "user", "content": "hello"}], + optional_params={ + "workspace_id": "wrkspc_test", + "thinking": {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}}, + }, + litellm_params={}, + ) + + assert "thinking-binding-controls-2026-08-01" in headers["anthropic-beta"].split(",") + + def test_claude_platform_does_not_use_standard_anthropic_api_key(monkeypatch): from litellm.llms.bedrock.claude_platform.transformation import ( BedrockClaudePlatformConfig, diff --git a/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py b/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py index eb2e1d8e347..19234767b19 100644 --- a/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py +++ b/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py @@ -2465,6 +2465,41 @@ def test_databricks_claude_drops_the_openai_tool_search_tool_under_drop_params(m ] +@pytest.mark.parametrize("drop_params", [True, False]) +def test_databricks_chat_drops_beta_gated_thinking_fields_only_under_drop_params( + drop_params: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + thinking: Final = { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + mapped: Final = DatabricksConfig().map_openai_params( + non_default_params={"thinking": thinking}, + optional_params={}, + model="databricks-claude-opus-5-5", + drop_params=drop_params, + ) + assert mapped["thinking"] == ({"type": "adaptive"} if drop_params else thinking) + assert thinking == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + + +def test_databricks_chat_keeps_ungated_thinking_display_under_drop_params(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + mapped: Final = DatabricksConfig().map_openai_params( + non_default_params={"thinking": {"type": "adaptive", "display": "summarized"}}, + optional_params={}, + model="databricks-claude-opus-5-5", + drop_params=True, + ) + assert mapped["thinking"] == {"type": "adaptive", "display": "summarized"} + + _JSON_TOOL_ARGUMENT_FRAGMENTS: Final = ("", "", '{"name": ', '"Ada"', ', "met', 'a": ', "{}", ', "tags": []}') _RECORD_RESPONSE_FORMAT: Final = { "type": "json_schema", diff --git a/tests/unit/llms/databricks/messages/test_databricks_messages_transformation.py b/tests/unit/llms/databricks/messages/test_databricks_messages_transformation.py index 3c5f4fd72f2..ae54d0e7437 100644 --- a/tests/unit/llms/databricks/messages/test_databricks_messages_transformation.py +++ b/tests/unit/llms/databricks/messages/test_databricks_messages_transformation.py @@ -281,7 +281,7 @@ SERVER_TOOLS: Final = [ ] -async def _workspace_request_body(**request_params: object) -> dict[str, object]: +async def _workspace_request(max_tokens: int = 64, **request_params: object) -> httpx.Request: received: Final[list[httpx.Request]] = [] # mutable-ok: the fake workspace records what litellm sent def workspace_answers(request: httpx.Request) -> httpx.Response: @@ -303,7 +303,7 @@ async def _workspace_request_body(**request_params: object) -> dict[str, object] upstream: Final = AsyncHTTPHandler(transport=httpx.MockTransport(workspace_answers)) try: await litellm.anthropic.messages.acreate( - max_tokens=64, + max_tokens=max_tokens, messages=USER_TURN, model=f"databricks/{CLAUDE_MODEL}", api_key=DATABRICKS_API_KEY, @@ -314,7 +314,30 @@ async def _workspace_request_body(**request_params: object) -> dict[str, object] finally: await upstream.close() assert len(received) == 1 - return json.loads(received[0].content) + return received[0] + + +async def _workspace_request_body(**request_params: object) -> dict[str, object]: + return json.loads((await _workspace_request(**request_params)).content) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("drop_params", [True, False]) +async def test_bound_thinking_with_display_updates_reaches_the_workspace_with_both_betas( + drop_params: bool, monkeypatch: pytest.MonkeyPatch, local_beta_headers_config: None +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + thinking: Final = { + "type": "enabled", + "budget_tokens": 1024, + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + request: Final = await _workspace_request(max_tokens=4096, thinking=thinking, drop_params=drop_params) + assert json.loads(request.content)["thinking"] == thinking + assert {"thinking-binding-controls-2026-08-01", "thinking-display-updates-2026-08-18"} <= set( + request.headers["anthropic-beta"].split(",") + ) @pytest.mark.asyncio diff --git a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py index 5916ce9f7a1..1e7c3d52814 100644 --- a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py +++ b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py @@ -289,7 +289,7 @@ 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): +def _validate_vertex_headers(client_headers, messages, thinking: Mapping[str, object] | None = None): config = VertexAIPartnerModelsAnthropicMessagesConfig() litellm_params = { "vertex_ai_project": "test-project", @@ -305,7 +305,7 @@ def _validate_vertex_headers(client_headers, messages): headers=client_headers, model="claude-opus-5-5", messages=messages, - optional_params={"max_tokens": 64}, + optional_params={"max_tokens": 64, **({"thinking": thinking} if thinking is not None else {})}, litellm_params=litellm_params, api_base=None, ) @@ -337,6 +337,25 @@ def test_per_message_output_config_reaches_vertex_with_per_turn_control_beta(cli assert filtered["anthropic-beta"].split(",").count("per-turn-control-2026-07-01") == 1 +def test_block_binding_reaches_vertex_messages_with_its_beta_under_drop_params(local_beta_headers_config: None) -> None: + from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta + + thinking: Final = {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}} + messages: Final = [{"role": "user", "content": "Say hi in French"}] + headers: Final = update_headers_with_filtered_beta( + headers=_validate_vertex_headers({}, messages, thinking=thinking), provider="vertex_ai" + ) + request: Final = VertexAIPartnerModelsAnthropicMessagesConfig().transform_anthropic_messages_request( + model="claude-opus-5-5", + messages=[dict(message) for message in messages], + anthropic_messages_optional_request_params={"max_tokens": 64, "thinking": dict(thinking)}, + litellm_params=GenericLiteLLMParams(drop_params=True), + headers=headers, + ) + assert "thinking-binding-controls-2026-08-01" in headers["anthropic-beta"].split(",") + assert request["thinking"] == thinking + + def test_no_per_message_output_config_leaves_per_turn_control_beta_out(): headers = _validate_vertex_headers({}, [{"role": "user", "content": "Hello"}]) @@ -913,6 +932,20 @@ class TestVertexAnthropicMidConversationSystem: {"type": "text", "text": "Cite sources."}, ] + @pytest.mark.parametrize("drop_params", (False, True)) + def test_unsupported_model_sends_a_non_object_message_on_for_the_provider_to_reject( + self, local_model_cost_map, local_beta_headers_config, drop_params + ): + messages: Final = [{"role": "user", "content": "hi"}, "oops"] + result: Final = VertexAIPartnerModelsAnthropicMessagesConfig().transform_anthropic_messages_request( + model="claude-sonnet-4-6", + messages=list(messages), + anthropic_messages_optional_request_params={"max_tokens": 64}, + litellm_params=GenericLiteLLMParams(drop_params=drop_params), + headers={}, + ) + assert result["messages"] == messages + def test_unsupported_model_converts_mid_conversation_system_in_place(self, local_model_cost_map): messages = [ {"role": "user", "content": "read the file"}, diff --git a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py index 1c432ec79da..3ae208bb3a8 100644 --- a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py +++ b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py @@ -155,6 +155,25 @@ def test_vertex_ai_anthropic_web_search_header_in_completion(): ), "anthropic-beta with web-search should not be present for non-Vertex requests" +def test_vertex_ai_anthropic_chat_adds_the_betas_for_bound_thinking_with_display_updates() -> None: + thinking: Final = { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + } + result: Final = VertexAIAnthropicConfig().transform_request( + model="claude-opus-4-8", + messages=[{"role": "user", "content": "Hello"}], + optional_params={"thinking": thinking, "max_tokens": 100, "is_vertex_request": True}, + litellm_params={}, + headers={}, + ) + assert result["thinking"] == thinking + assert {"thinking-binding-controls-2026-08-01", "thinking-display-updates-2026-08-18"} <= set( + result["anthropic_beta"] + ) + + def test_vertex_ai_anthropic_context_management_compact_beta_header(): """Test that context_management with compact adds the correct beta header for Vertex AI""" config = VertexAIAnthropicConfig() diff --git a/tests/unit/test_anthropic_beta_gated_fields.py b/tests/unit/test_anthropic_beta_gated_fields.py new file mode 100644 index 00000000000..a2b384b4ac4 --- /dev/null +++ b/tests/unit/test_anthropic_beta_gated_fields.py @@ -0,0 +1,301 @@ +import logging +from typing import Final + +import pytest + +import litellm +from litellm.anthropic_beta_gated_fields import ( + ANTHROPIC_INLINE_TOOLS_BETA_HEADER, + ANTHROPIC_MID_CONVERSATION_SYSTEM_CLEAR_AT_BETA_HEADER, + ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER, + BETA_GATED_FIELDS, + active_beta_values, + log_beta_gated_strips, + strip_beta_gated_native_request, + strip_beta_gated_request_fields, + strip_beta_gated_system_messages, + strip_beta_gated_thinking, +) +from litellm.types.llms.anthropic import ( + ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER, + ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER, + ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER, +) + +EVERY_BETA: Final = frozenset(BETA_GATED_FIELDS) +BOUND_THINKING: Final = { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", +} + + +def _system_turn() -> dict[str, object]: + return { + "role": "system", + "clear_at": "end_turn", + "output_config": {"effort": "low"}, + "content": [ + {"type": "text", "text": "stay terse"}, + {"type": "tool_addition", "tools": [{"name": "lookup"}]}, + {"type": "tool_removal", "tool_names": ["lookup"]}, + ], + } + + +def test_thinking_with_every_beta_active_is_returned_as_is() -> None: + result: Final = strip_beta_gated_thinking(BOUND_THINKING, EVERY_BETA, drop_params=True) + assert result.thinking is BOUND_THINKING + assert result.stripped == () + + +def test_thinking_loses_only_the_field_whose_beta_is_absent() -> None: + without_binding: Final = strip_beta_gated_thinking( + BOUND_THINKING, EVERY_BETA - {ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER}, drop_params=True + ) + assert without_binding.thinking == {"type": "adaptive", "display": "updates"} + assert without_binding.stripped == ("thinking.block_binding",) + + without_display: Final = strip_beta_gated_thinking( + BOUND_THINKING, EVERY_BETA - {ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER}, drop_params=True + ) + assert without_display.thinking == {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}} + assert without_display.stripped == ("thinking.display=updates",) + assert BOUND_THINKING == { + "type": "adaptive", + "block_binding": {"prefix_mismatch_behavior": "error"}, + "display": "updates", + }, "copy-on-write: the caller's thinking dict must survive for the fallback leg" + + +def test_thinking_display_other_than_updates_is_not_gated() -> None: + thinking: Final = {"type": "adaptive", "display": "summarized"} + result: Final = strip_beta_gated_thinking(thinking, frozenset(), drop_params=True) + assert result.thinking is thinking + + +@pytest.mark.parametrize("thinking", [None, "enabled", 12, ["adaptive"]]) +def test_non_mapping_thinking_passes_through(thinking: object) -> None: + result: Final = strip_beta_gated_thinking(thinking, frozenset(), drop_params=True) + assert result.thinking is thinking + assert result.stripped == () + + +def test_system_message_keeps_every_gated_field_when_the_betas_are_active() -> None: + messages: Final = [{"role": "user", "content": "hi"}, _system_turn()] + result: Final = strip_beta_gated_system_messages(messages, EVERY_BETA, drop_params=True) + assert result.messages is messages + assert result.stripped == () + + +def test_system_message_loses_exactly_the_fields_whose_betas_are_absent() -> None: + original: Final = _system_turn() + messages: Final = [{"role": "user", "content": "hi"}, original] + result: Final = strip_beta_gated_system_messages( + messages, + EVERY_BETA + - { + ANTHROPIC_MID_CONVERSATION_SYSTEM_CLEAR_AT_BETA_HEADER, + ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER, + ANTHROPIC_INLINE_TOOLS_BETA_HEADER, + }, + drop_params=True, + ) + assert result.messages == [ + {"role": "user", "content": "hi"}, + { + "role": "system", + "output_config": {"effort": "low"}, + "content": [{"type": "text", "text": "stay terse"}], + }, + ] + assert result.stripped == ( + "messages[].system.clear_at", + "messages[].system.content[].tool_addition|tool_removal", + ) + assert original == _system_turn(), "copy-on-write: the caller's system message must survive" + + +def test_per_turn_control_beta_also_unlocks_system_output_config() -> None: + messages: Final = [{"role": "system", "output_config": {"effort": "low"}, "content": "terse"}] + assert ( + strip_beta_gated_system_messages( + messages, frozenset({"per-turn-control-2026-07-01"}), drop_params=True + ).messages + is messages + ) + assert ( + strip_beta_gated_system_messages( + messages, frozenset({ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER}), drop_params=True + ).messages + is messages + ) + assert strip_beta_gated_system_messages(messages, frozenset(), drop_params=True).messages == [ + {"role": "system", "content": "terse"} + ] + + +def test_inline_tools_beta_also_unlocks_system_tool_changes() -> None: + messages: Final = [ + { + "role": "system", + "content": [{"type": "tool_removal", "tool_names": ["lookup"]}, {"type": "text", "text": "x"}], + } + ] + assert ( + strip_beta_gated_system_messages( + messages, frozenset({ANTHROPIC_INLINE_TOOLS_BETA_HEADER}), drop_params=True + ).messages + is messages + ) + assert ( + strip_beta_gated_system_messages( + messages, frozenset({ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER}), drop_params=True + ).messages + is messages + ) + assert strip_beta_gated_system_messages(messages, frozenset(), drop_params=True).messages == [ + {"role": "system", "content": [{"type": "text", "text": "x"}]} + ] + + +def test_system_block_with_unhashable_type_is_kept_for_the_provider_to_reject() -> None: + messages: Final = [{"role": "system", "content": [{"type": [], "text": "x"}]}] + result: Final = strip_beta_gated_system_messages(messages, frozenset(), drop_params=True) + assert result.messages is messages + assert result.stripped == () + + +def test_system_message_emptied_by_the_strip_is_dropped() -> None: + messages: Final = [ + {"role": "user", "content": "hi"}, + {"role": "system", "content": [{"type": "tool_removal", "tool_names": ["lookup"]}]}, + {"role": "assistant", "content": "ok"}, + ] + result: Final = strip_beta_gated_system_messages(messages, frozenset(), drop_params=True) + assert result.messages == [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "ok"}] + assert result.stripped == ( + "messages[].system.content[].tool_addition|tool_removal", + "messages[].system (emptied by the strip)", + ) + + +def test_emptied_trailing_system_turn_after_an_assistant_turn_is_sent_as_is() -> None: + messages: Final = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "ok"}, + {"role": "system", "content": [{"type": "tool_removal", "tool_names": ["lookup"]}]}, + ] + result: Final = strip_beta_gated_system_messages(messages, frozenset(), drop_params=True) + assert result.messages is messages + assert result.stripped == () + + +def test_emptied_system_turn_is_dropped_when_a_user_turn_still_ends_the_conversation() -> None: + messages: Final = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "ok"}, + {"role": "system", "content": [{"type": "tool_removal", "tool_names": ["lookup"]}]}, + {"role": "user", "content": "go on"}, + ] + result: Final = strip_beta_gated_system_messages(messages, frozenset(), drop_params=True) + assert [message["role"] for message in result.messages] == ["user", "assistant", "user"] + + +def test_system_turn_emptied_by_the_strip_stays_as_a_directive_when_its_output_config_survives() -> None: + directive: Final = { + "role": "system", + "output_config": {"effort": "low"}, + "content": [{"type": "tool_removal", "tool_names": ["lookup"]}], + } + result: Final = strip_beta_gated_system_messages( + [directive], frozenset({ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER}), drop_params=True + ) + assert result.messages == [{"role": "system", "output_config": {"effort": "low"}, "content": []}] + assert result.stripped == ("messages[].system.content[].tool_addition|tool_removal",) + + +def test_system_turn_emptied_by_the_strip_is_dropped_when_its_output_config_is_gated_too() -> None: + directive: Final = { + "role": "system", + "output_config": {"effort": "low"}, + "content": [{"type": "tool_removal", "tool_names": ["lookup"]}], + } + result: Final = strip_beta_gated_system_messages([directive], frozenset(), drop_params=True) + assert result.messages == [] + assert result.stripped[-1] == "messages[].system (emptied by the strip)" + + +def test_request_strip_replaces_only_thinking_and_messages_and_keeps_the_dict_otherwise() -> None: + request: Final = { + "model": "claude", + "thinking": dict(BOUND_THINKING), + "messages": [_system_turn()], + "max_tokens": 5, + } + result: Final = strip_beta_gated_request_fields(request, frozenset(), drop_params=True) + assert result.request == { + "model": "claude", + "thinking": {"type": "adaptive"}, + "messages": [{"role": "system", "content": [{"type": "text", "text": "stay terse"}]}], + "max_tokens": 5, + } + assert result.request is not request + assert request["thinking"] == BOUND_THINKING + assert request["messages"] == [_system_turn()] + + +def test_request_strip_is_identity_when_nothing_is_gated() -> None: + request: Final = { + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "messages": [{"role": "user", "content": "hi"}], + } + result: Final = strip_beta_gated_request_fields(request, frozenset(), drop_params=True) + assert result.request is request + assert result.stripped == () + + +@pytest.mark.parametrize("drop_params", [None, False, "false"]) +def test_without_drop_params_every_gated_field_is_sent_as_is( + drop_params: object, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + messages: Final = [{"role": "user", "content": "hi"}, _system_turn()] + request: Final = {"thinking": BOUND_THINKING, "messages": messages} + + assert strip_beta_gated_thinking(BOUND_THINKING, frozenset(), drop_params=drop_params).thinking is BOUND_THINKING + assert strip_beta_gated_system_messages(messages, frozenset(), drop_params=drop_params).messages is messages + assert strip_beta_gated_request_fields(request, frozenset(), drop_params=drop_params).request is request + native: Final = strip_beta_gated_native_request({}, {"thinking": BOUND_THINKING}, messages, drop_params=drop_params) + assert (native.thinking, list(native.messages), native.thinking_stripped, native.messages_stripped) == ( + BOUND_THINKING, + messages, + False, + False, + ) + + +def test_global_drop_params_strips_without_a_per_call_flag(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "drop_params", True) + assert strip_beta_gated_thinking(BOUND_THINKING, frozenset(), drop_params=None).thinking == {"type": "adaptive"} + + +def test_active_beta_values_reads_the_header_case_insensitively_and_merges_body_betas() -> None: + assert active_beta_values({"Anthropic-Beta": "a-2026-01-01, b-2026-01-01 ,"}, ["c-2026-01-01", 7]) == frozenset( + {"a-2026-01-01", "b-2026-01-01", "c-2026-01-01"} + ) + assert active_beta_values({}) == frozenset() + + +def test_every_strip_is_logged_once_at_warning_with_the_fields_model_and_provider( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + log_beta_gated_strips(("thinking.block_binding", "thinking.block_binding"), model="claude", provider="azure_ai") + log_beta_gated_strips((), model="claude", provider="azure_ai") + warnings: Final = [record for record in caplog.records if record.levelno == logging.WARNING] + assert len(warnings) == 1 + assert ( + "Dropped thinking.block_binding (unlocked by anthropic-beta thinking-binding-controls-2026-08-01) for " + "model=claude provider=azure_ai" in warnings[0].getMessage() + )