mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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.
This commit is contained in:
parent
ab8982154d
commit
8983631cc5
14 changed files with 204 additions and 51 deletions
|
|
@ -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=())
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue