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.
This commit is contained in:
mateo-berri 2026-10-28 13:54:17 -07:00
parent e2552fe052
commit b42590dc20
21 changed files with 259 additions and 76 deletions

View file

@ -23,10 +23,10 @@ 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_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"
@ -156,7 +156,7 @@ def strip_beta_gated_thinking(thinking: object, active_betas: Set[str], *, drop_
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 no content, since an empty system turn is itself a 400."""
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")
@ -184,7 +184,7 @@ def _strip_beta_gated_system_message(message: Mapping[str, object], active_betas
*((SYSTEM_OUTPUT_CONFIG,) if output_config_gated else ()),
*((SYSTEM_TOOL_CHANGES,) if tool_changes_gated else ()),
)
if kept_content in (None, "", []):
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)
@ -292,9 +292,10 @@ def log_beta_gated_strips(stripped: Sequence[str], model: str | None, provider:
if not stripped:
return
verbose_logger.warning(
"Stripped %s from the request for model=%s provider=%s because drop_params is on and the request "
"does not send the anthropic-beta that gates them on this provider (see anthropic_beta_headers_config.json). "
"Send that beta to keep them where the provider accepts it.",
"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 the anthropic-beta that unlocks them (see "
"anthropic_beta_headers_config.json) or since the model turns mid-conversation system messages into user "
"messages.",
sorted(set(stripped)),
model,
provider,

View file

@ -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"

View file

@ -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,
@ -552,6 +553,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":
@ -1132,7 +1147,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]:
"""
@ -1169,13 +1184,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(
@ -1237,7 +1249,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:
@ -1298,7 +1310,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 (),
)
@ -1385,7 +1397,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),

View file

@ -26,7 +26,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 (
@ -895,17 +894,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}

View file

@ -107,6 +107,7 @@ 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}

View file

@ -1829,7 +1829,15 @@ 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 {})
body_beta_list: Final = sorted(
active_beta_values(
{},
additional_request_params.get("anthropic_beta"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # additional_request_params is a bare dict
)
- set(header_beta_list)
)
client_beta_list: Final = [*header_beta_list, *body_beta_list]
eager_beta: Final = (
(ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER,)
if base_model.startswith("anthropic")
@ -1837,7 +1845,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):
@ -1907,7 +1923,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

View file

@ -303,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 = (
@ -321,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)

View file

@ -7,11 +7,7 @@ from pydantic import TypeAdapter
import litellm
from litellm._logging import verbose_logger
from litellm.anthropic_beta_gated_fields import (
ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER,
log_beta_gated_strips,
strip_beta_gated_native_request,
)
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,
@ -589,18 +585,13 @@ 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
),
)
beta_set.update(auto_betas)
if "block_binding" in (anthropic_messages_request.get("thinking") or {}): # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # the base signature types the request as a bare dict
beta_set.add(ANTHROPIC_THINKING_BINDING_CONTROLS_BETA_HEADER)
if injected_thinking_for_clear_thinking:
beta_set.add("interleaved-thinking-2025-05-14")

View file

@ -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

View file

@ -124,6 +124,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)

View file

@ -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"

View file

@ -3617,9 +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(
def _anthropic_chat_request_for_bound_thinking(
extra_headers: dict[str, str] | None, drop_params: bool | None = None
) -> dict[str, object]:
) -> httpx.Request:
captured_requests: Final[list[httpx.Request]] = []
def respond(request: httpx.Request) -> httpx.Response:
@ -3653,7 +3653,13 @@ def _anthropic_chat_body_for_bound_thinking(
finally:
client.close()
assert thinking == {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}}
return json.loads(captured_requests[0].content)
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(
@ -3666,12 +3672,18 @@ def test_anthropic_chat_keeps_block_binding_when_the_final_header_carries_the_bi
assert body["thinking"] == {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}}
def test_anthropic_chat_drops_block_binding_when_no_header_carries_the_binding_beta(
monkeypatch: pytest.MonkeyPatch,
@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")
body: Final = _anthropic_chat_body_for_bound_thinking(None, drop_params=True)
assert body["thinking"] == {"type": "adaptive"}
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:

View file

@ -422,6 +422,22 @@ 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 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",

View file

@ -4294,7 +4294,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",
@ -4302,6 +4309,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding(
{"type": "enabled", "budget_tokens": 2048},
{"not_a_thinking_key": True},
{"type": "adaptive"},
False,
),
(
"bedrock",
@ -4309,6 +4317,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding(
{"type": "enabled", "budget_tokens": 2048},
{"not_a_thinking_key": True},
{"type": "adaptive"},
False,
),
(
"bedrock",
@ -4316,6 +4325,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding(
{"type": "enabled", "budget_tokens": 2048},
{"not_a_thinking_key": True},
{"type": "adaptive"},
False,
),
(
"bedrock",
@ -4323,6 +4333,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",
@ -4330,6 +4341,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding(
{"type": "enabled", "budget_tokens": 2048},
{"not_a_thinking_key": True},
{"type": "adaptive"},
False,
),
(
"databricks",
@ -4337,6 +4349,7 @@ def test_shared_legacy_thinking_translation_carries_block_binding(
{"type": "enabled", "budget_tokens": 2048},
{"not_a_thinking_key": True},
{"type": "adaptive"},
True,
),
),
ids=(
@ -4355,6 +4368,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:
@ -4369,7 +4383,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")

View file

@ -277,7 +277,7 @@ def test_completion_surfaces_the_status_and_headers_of_a_rejected_request():
@respx.mock
@pytest.mark.parametrize("drop_params", [True, False])
def test_completion_drops_block_binding_without_its_beta_only_under_drop_params(
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)
@ -308,5 +308,5 @@ def test_completion_drops_block_binding_without_its_beta_only_under_drop_params(
)
sent: Final = json.loads(route.calls.last.request.content)
assert sent["thinking"]["budget_tokens"] == 1024
assert ("block_binding" in sent["thinking"]) is not drop_params
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(",")

View file

@ -2011,29 +2011,22 @@ def test_invoke_uses_opt_in_native_output_is_false_when_native_output_did_not_co
@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config")
@pytest.mark.parametrize(
("drop_params", "headers", "keeps_block_binding"),
(
(True, {}, False),
(False, {}, True),
(True, {"anthropic-beta": "thinking-binding-controls-2026-08-01"}, True),
),
ids=("drop-params-no-beta", "no-drop-params", "drop-params-with-beta"),
)
def test_bedrock_chat_invoke_drops_block_binding_only_when_drop_params_and_no_beta_gates_it(
drop_params: bool, headers: dict[str, str], keeps_block_binding: bool, monkeypatch: pytest.MonkeyPatch
@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": {"prefix_mismatch_behavior": "error"}},
"thinking": {"type": "enabled", "budget_tokens": 1024, "block_binding": block_binding},
},
litellm_params={"drop_params": drop_params},
headers=headers,
headers={},
)
assert result["thinking"]["budget_tokens"] == 1024
assert ("block_binding" in result["thinking"]) is keeps_block_binding
assert result["thinking"] == {"type": "enabled", "budget_tokens": 1024, "block_binding": block_binding}
assert "thinking-binding-controls-2026-08-01" in result["anthropic_beta"]

View file

@ -10832,17 +10832,32 @@ def test_converse_keeps_beta_gated_thinking_fields_the_bedrock_converse_row_carr
}
def test_converse_drops_beta_gated_thinking_fields_when_the_request_carries_no_beta(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
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")
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
fields: Final = _converse_bound_thinking_request(None)
assert fields["thinking"] == {"type": "adaptive"}
assert any(
"thinking.block_binding" in record.getMessage() and "provider=bedrock_converse" in record.getMessage()
for record in caplog.records
)
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:

View file

@ -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",

View file

@ -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,31 @@ 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
) -> None:
monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "true")
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

View file

@ -127,6 +127,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()

View file

@ -168,6 +168,30 @@ def test_system_message_emptied_by_the_strip_is_dropped() -> None:
)
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",