From 8983631cc5d47aa9e401f94caecda68eb0f88c64 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:55:17 -0700 Subject: [PATCH] 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. --- litellm/anthropic_beta_gated_fields.py | 39 ++++++--- litellm/llms/anthropic/chat/handler.py | 1 + .../messages/native/transformation.py | 13 ++- .../anthropic/messages_transformation.py | 4 +- .../bedrock/chat/converse_transformation.py | 1 + .../anthropic_claude_transformation.py | 10 ++- .../databricks/messages/transformation.py | 4 +- .../anthropic/messages/transformation.py | 4 +- .../test_beta_gated_fields_wire.py | 30 ++++++- .../chat/test_anthropic_chat_handler.py | 18 +++- .../test_anthropic_messages_transformation.py | 18 +++- .../chat/test_converse_transformation.py | 14 +++- .../test_anthropic_claude_transformation.py | 16 ++-- .../unit/test_anthropic_beta_gated_fields.py | 83 ++++++++++++++++--- 14 files changed, 204 insertions(+), 51 deletions(-) diff --git a/litellm/anthropic_beta_gated_fields.py b/litellm/anthropic_beta_gated_fields.py index ed5647e58b7..984c0e0987a 100644 --- a/litellm/anthropic_beta_gated_fields.py +++ b/litellm/anthropic_beta_gated_fields.py @@ -2,10 +2,12 @@ ``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, so the same request degrades on that leg by -dropping those fields too. 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. +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 @@ -16,6 +18,7 @@ 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, @@ -25,6 +28,7 @@ from litellm.types.llms.anthropic import ( ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER: Final = "thinking-binding-controls-2026-08-01" 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" @@ -43,6 +47,7 @@ BETA_GATED_FIELDS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType( 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,), } ) @@ -125,10 +130,10 @@ class StrippedRequest: stripped: tuple[str, ...] -def strip_beta_gated_thinking(thinking: object, active_betas: Set[str]) -> StrippedThinking: +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: + 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( @@ -191,9 +196,13 @@ def _strip_beta_gated_message(message: object, active_betas: Set[str]) -> Stripp return _strip_beta_gated_system_message(mapping, active_betas) -def strip_beta_gated_system_messages(messages: Sequence[object], active_betas: Set[str]) -> StrippedMessages: +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.""" + 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 @@ -208,14 +217,16 @@ def strip_beta_gated_system_messages(messages: Sequence[object], active_betas: S ) -def strip_beta_gated_request_fields(request: Mapping[str, object], active_betas: Set[str]) -> StrippedRequest: +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) + 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) + strip_beta_gated_system_messages(message_sequence, active_betas, drop_params=drop_params) if message_sequence is not None else StrippedMessages(messages=(), stripped=()) ) @@ -253,16 +264,18 @@ def unstripped_native_request(optional_params: object, messages: object) -> Stri def strip_beta_gated_native_request( - headers: object, optional_params: object, messages: object, body_betas: object = () + 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) + 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) + strip_beta_gated_system_messages(message_sequence, active_betas, drop_params=drop_params) if message_sequence is not None else StrippedMessages(messages=(), stripped=()) ) diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index b46509f9317..9a15fff6612 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -518,6 +518,7 @@ class AnthropicChatCompletion(BaseLLM): gated: Final = strip_beta_gated_request_fields( filtered_data, active_beta_values(request_headers, filtered_data.get("anthropic_beta") or ()), + drop_params=litellm_params.get("drop_params"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # litellm_params is the handler's bare dict ) log_beta_gated_strips(gated.stripped, model=model, provider=custom_llm_provider) data: Final = dict(gated.request) diff --git a/litellm/llms/anthropic/messages/native/transformation.py b/litellm/llms/anthropic/messages/native/transformation.py index ec5e71dd19e..5b8fc1a8149 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -228,7 +228,9 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): else: return system_param - def _normalize_system_role_messages(self, anthropic_messages_request: dict, model: str) -> None: + 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. @@ -278,7 +280,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): gated: Final = ( StrippedMessages(messages=trailing_messages, stripped=()) if keeps_system_turns - else strip_beta_gated_system_messages(trailing_messages, frozenset()) + else strip_beta_gated_system_messages(trailing_messages, frozenset(), drop_params=drop_params) ) log_beta_gated_strips(gated.stripped, model=model, provider=self.custom_llm_provider or "anthropic") remaining: Final = ( @@ -732,7 +734,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): messages = strip_advisor_blocks_from_messages(messages) gated: Final = ( - strip_beta_gated_native_request(headers, anthropic_messages_optional_request_params, messages) # pyright: ignore[reportUnknownArgumentType] # the base signature types these as bare dicts + 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 ) 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/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 04059570347..409c6cb5dbc 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -2037,6 +2037,7 @@ class AmazonConverseConfig(BaseConfig): 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") 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 b6d5323f0f6..2aa666fe4c4 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py @@ -765,7 +765,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 ### ######################################################### @@ -835,7 +837,11 @@ class AmazonAnthropicClaudeMessagesConfig( 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 + {}, + 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: diff --git a/litellm/llms/databricks/messages/transformation.py b/litellm/llms/databricks/messages/transformation.py index e22ad85b5f9..f5343445957 100644 --- a/litellm/llms/databricks/messages/transformation.py +++ b/litellm/llms/databricks/messages/transformation.py @@ -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 6d8eb228786..525c247590d 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 @@ -208,7 +208,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/tests/integration/messages_endpoint/test_beta_gated_fields_wire.py b/tests/integration/messages_endpoint/test_beta_gated_fields_wire.py index e13ac58d797..26ad9f20897 100644 --- a/tests/integration/messages_endpoint/test_beta_gated_fields_wire.py +++ b/tests/integration/messages_endpoint/test_beta_gated_fields_wire.py @@ -16,6 +16,7 @@ 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})") @@ -51,10 +52,15 @@ def respond(request: Request) -> Reply: return claude_reply(request) -def deployment(name: str, model: str, api_base: str) -> dict[str, JsonValue]: +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"}, + "litellm_params": { + "model": model, + "api_base": api_base, + "api_key": f"synthetic-{name}-key", + **({"drop_params": True} if drop_params else {}), + }, } @@ -66,6 +72,9 @@ def write_config(directory: Path, wire_url: str) -> Path: "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", @@ -139,13 +148,26 @@ def system_message(body: bytes) -> dict[str, JsonValue]: return system[0] -def test_a_leg_whose_row_lacks_the_beta_sends_neither_the_beta_nor_clear_at(rig: Rig) -> None: +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"} + 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: 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 b3389c54cc0..4cc4e4de6f8 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -3617,7 +3617,9 @@ def test_streamed_end_turn_carries_no_stop_details(): assert "stop_reason" not in provider_fields -def _anthropic_chat_body_for_bound_thinking(extra_headers: dict[str, str] | None) -> dict[str, object]: +def _anthropic_chat_body_for_bound_thinking( + extra_headers: dict[str, str] | None, drop_params: bool | None = None +) -> dict[str, object]: captured_requests: Final[list[httpx.Request]] = [] def respond(request: httpx.Request) -> httpx.Response: @@ -3646,6 +3648,7 @@ def _anthropic_chat_body_for_bound_thinking(extra_headers: dict[str, str] | None client=client, thinking=thinking, extra_headers=extra_headers, + drop_params=drop_params, ) finally: client.close() @@ -3657,7 +3660,9 @@ def test_anthropic_chat_keeps_block_binding_when_the_final_header_carries_the_bi 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"}) + 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"}} @@ -3665,5 +3670,12 @@ def test_anthropic_chat_drops_block_binding_when_no_header_carries_the_binding_b monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true") - body: Final = _anthropic_chat_body_for_bound_thinking(None) + body: Final = _anthropic_chat_body_for_bound_thinking(None, drop_params=True) assert body["thinking"] == {"type": "adaptive"} + + +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 47d1f600cf6..783692ab922 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 @@ -422,7 +422,7 @@ def test_drop_params_forwards_a_non_object_message_item_as_sent_on_the_native_re BINDING_AND_DISPLAY_BETAS: Final = "thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18" -def _bound_adaptive_request(headers: dict[str, str]) -> dict[str, object]: +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"}, @@ -433,7 +433,7 @@ def _bound_adaptive_request(headers: dict[str, str]) -> dict[str, object]: model="claude-opus-4-8", messages=[{"role": "user", "content": "hi"}], anthropic_messages_optional_request_params=optional_params, - litellm_params=GenericLiteLLMParams(), + litellm_params=GenericLiteLLMParams(drop_params=drop_params), headers=headers, ) assert thinking == { @@ -462,13 +462,25 @@ def test_messages_request_drops_beta_gated_thinking_fields_the_final_header_does 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(), + litellm_params=GenericLiteLLMParams(drop_params=True), headers={"anthropic-beta": "mid-conversation-system-2026-07-01"}, ) assert request["messages"][1] == {"role": "system", "content": "be terse"} diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 963f5ea9781..5033f8f7036 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -10795,7 +10795,7 @@ def test_a_deployment_with_a_batch_control_plane_endpoint_keeps_it_out_of_chat_r def _converse_bound_thinking_request( - headers: dict[str, str] | None, body_betas: tuple[str, ...] = () + headers: dict[str, str] | None, body_betas: tuple[str, ...] = (), drop_params: bool = True ) -> dict[str, object]: thinking: Final = { "type": "adaptive", @@ -10808,6 +10808,7 @@ def _converse_bound_thinking_request( 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", @@ -10844,6 +10845,17 @@ def test_converse_drops_beta_gated_thinking_fields_when_the_request_carries_no_b ) +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: 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 2204b5f417f..d99d3e786a4 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 @@ -4215,7 +4215,7 @@ def test_bedrock_messages_keeps_mid_conversation_output_config_its_auto_added_be model="anthropic.claude-opus-4-8-v1:0", messages=messages, anthropic_messages_optional_request_params={"max_tokens": 4096}, - litellm_params=GenericLiteLLMParams(), + litellm_params=GenericLiteLLMParams(drop_params=True), headers={}, ) @@ -4233,9 +4233,13 @@ _SENT_MESSAGES: Final = TypeAdapter(list[_SentMessage]) @pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config") -def test_bedrock_messages_drops_tool_changes_from_a_system_turn_an_older_model_gets_as_a_user_turn() -> None: - """A model without mid-conversation system support sends no tool-changes beta, so the converted turn - must not carry the blocks that beta gates.""" +@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 = [ @@ -4254,7 +4258,7 @@ def test_bedrock_messages_drops_tool_changes_from_a_system_turn_an_older_model_g model="anthropic.claude-3-7-sonnet-20250219-v1:0", messages=messages, anthropic_messages_optional_request_params={"max_tokens": 4096}, - litellm_params=GenericLiteLLMParams(), + litellm_params=GenericLiteLLMParams(drop_params=drop_params), headers={}, ) @@ -4263,6 +4267,6 @@ def test_bedrock_messages_drops_tool_changes_from_a_system_turn_an_older_model_g 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 all(block["type"] != "tool_addition" for block in blocks) + 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) diff --git a/tests/unit/test_anthropic_beta_gated_fields.py b/tests/unit/test_anthropic_beta_gated_fields.py index cd266497db0..5d596f66ae4 100644 --- a/tests/unit/test_anthropic_beta_gated_fields.py +++ b/tests/unit/test_anthropic_beta_gated_fields.py @@ -3,12 +3,15 @@ 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, @@ -41,20 +44,20 @@ def _system_turn() -> dict[str, object]: def test_thinking_with_every_beta_active_is_returned_as_is() -> None: - result: Final = strip_beta_gated_thinking(BOUND_THINKING, EVERY_BETA) + 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} + 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} + 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",) @@ -67,20 +70,20 @@ def test_thinking_loses_only_the_field_whose_beta_is_absent() -> None: 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()) + 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()) + 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) + result: Final = strip_beta_gated_system_messages(messages, EVERY_BETA, drop_params=True) assert result.messages is messages assert result.stripped == () @@ -91,7 +94,12 @@ def test_system_message_loses_exactly_the_fields_whose_betas_are_absent() -> Non 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_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"}, @@ -110,14 +118,40 @@ def test_system_message_loses_exactly_the_fields_whose_betas_are_absent() -> Non 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"})).messages is messages + 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}) + messages, frozenset({ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER}), drop_params=True ).messages is messages ) - assert strip_beta_gated_system_messages(messages, frozenset()).messages == [{"role": "system", "content": "terse"}] + 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_message_emptied_by_the_strip_is_dropped() -> None: @@ -126,7 +160,7 @@ def test_system_message_emptied_by_the_strip_is_dropped() -> None: {"role": "system", "content": [{"type": "tool_removal", "tool_names": ["lookup"]}]}, {"role": "assistant", "content": "ok"}, ] - result: Final = strip_beta_gated_system_messages(messages, frozenset()) + 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", @@ -141,7 +175,7 @@ def test_request_strip_replaces_only_thinking_and_messages_and_keeps_the_dict_ot "messages": [_system_turn()], "max_tokens": 5, } - result: Final = strip_beta_gated_request_fields(request, frozenset()) + result: Final = strip_beta_gated_request_fields(request, frozenset(), drop_params=True) assert result.request == { "model": "claude", "thinking": {"type": "adaptive"}, @@ -158,11 +192,34 @@ def test_request_strip_is_identity_when_nothing_is_gated() -> None: "thinking": {"type": "enabled", "budget_tokens": 1024}, "messages": [{"role": "user", "content": "hi"}], } - result: Final = strip_beta_gated_request_fields(request, frozenset()) + 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"}