fix(passthrough): drop top-level additional_drop_params on /v1/messages (#31645)

* fix(passthrough): drop top-level additional_drop_params on /v1/messages

On the Anthropic Messages pass-through path, additional_drop_params only
stripped nested dotted paths, so plain top-level keys like `thinking` and
`context_management` were forwarded to the provider. Bedrock rejects these
with "Extra inputs are not permitted", returning a 400 to Claude App/CLI
even when the user configured `additional_drop_params: ["thinking"]`.

delete_nested_value already handles plain top-level fields, so route every
drop param through it and remove the nested-only filter. Fixes #25931.

* fix(passthrough): drop thinking for bedrock inference-profile ARNs on /v1/messages

Opaque Bedrock Application Inference Profile ARNs contain neither "anthropic"
nor "claude", so is_anthropic_claude_model returned False and the thinking
param was rewritten to reasoning_effort before additional_drop_params ran.
That made additional_drop_params: ["thinking"] a no-op for the converse-ARN
form, and the Bedrock Converse transform re-expanded reasoning_effort back into
additionalModelRequestFields.thinking, so the request 400'd.

Extend the thinking-translation gates to also accept bedrock ARNs via the
existing is_bedrock_arn_model helper, mirroring the cache_control path, so
thinking is preserved as thinking and additional_drop_params can drop it.
This commit is contained in:
Mateo Wang 2026-06-29 18:17:12 -07:00 committed by GitHub
parent ddba2e2b15
commit 26ee5dd597
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 10 deletions

View file

@ -673,7 +673,9 @@ class LiteLLMAnthropicMessagesAdapter:
Returns:
Dict with either 'thinking' or 'reasoning_effort' key
"""
if LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(model):
if LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(
model
) or LiteLLMAnthropicMessagesAdapter.is_bedrock_arn_model(model):
return {"thinking": thinking}
else:
reasoning_effort = LiteLLMAnthropicMessagesAdapter.translate_anthropic_thinking_to_reasoning_effort(
@ -965,7 +967,7 @@ class LiteLLMAnthropicMessagesAdapter:
return
model = new_kwargs.get("model", "")
if self.is_anthropic_claude_model(model):
if self.is_anthropic_claude_model(model) or self.is_bedrock_arn_model(model):
new_kwargs["thinking"] = thinking # type: ignore
return

View file

@ -2001,16 +2001,11 @@ class BaseLLMHTTPHandler:
custom_llm_provider=custom_llm_provider,
)
# Apply additional_drop_params for nested field removal
additional_drop_params = litellm_params.get("additional_drop_params")
additional_drop_params: list[str] = litellm_params.get("additional_drop_params") or []
if additional_drop_params:
from litellm.litellm_core_utils.dot_notation_indexing import (
delete_nested_value,
is_nested_path,
)
from litellm.litellm_core_utils.dot_notation_indexing import delete_nested_value
nested_paths = [p for p in additional_drop_params if is_nested_path(p)]
for path in nested_paths:
for path in additional_drop_params:
anthropic_messages_optional_request_params = delete_nested_value(
anthropic_messages_optional_request_params, path
)

View file

@ -1508,6 +1508,44 @@ def test_cache_control_fix_does_not_broaden_claude_detection():
)
def test_thinking_preserved_for_bedrock_arn_inference_profile():
"""
Regression: opaque Bedrock Application Inference Profile ARNs hide the underlying
Claude model name, so on /v1/messages a `thinking` param must be preserved as
`thinking` (not rewritten to `reasoning_effort`). Otherwise `additional_drop_params:
["thinking"]` runs after the rewrite and has nothing left to drop, and the Bedrock
Converse body re-expands reasoning_effort back into additionalModelRequestFields.thinking.
"""
adapter = LiteLLMAnthropicMessagesAdapter()
thinking = {"type": "enabled", "budget_tokens": 1024}
new_kwargs = {"model": CACHE_CONTROL_BEDROCK_ARN_MODEL}
adapter._translate_thinking_to_openai(cast(Any, {"thinking": thinking}), cast(Any, new_kwargs))
assert new_kwargs["thinking"] == thinking
assert "reasoning_effort" not in new_kwargs
assert LiteLLMAnthropicMessagesAdapter.translate_thinking_for_model(thinking, CACHE_CONTROL_BEDROCK_ARN_MODEL) == {
"thinking": thinking
}
def test_thinking_still_translated_to_reasoning_effort_for_non_claude_model():
"""
The bedrock-ARN gate must not broaden to every model: a genuine non-Claude model
still has `thinking` converted to `reasoning_effort` so it does not hit an
UnsupportedParamsError downstream.
"""
adapter = LiteLLMAnthropicMessagesAdapter()
thinking = {"type": "enabled", "budget_tokens": 1024}
new_kwargs = {"model": CACHE_CONTROL_NON_ANTHROPIC_MODEL}
adapter._translate_thinking_to_openai(cast(Any, {"thinking": thinking}), cast(Any, new_kwargs))
assert "thinking" not in new_kwargs
assert new_kwargs["reasoning_effort"] == "low"
def test_cache_control_preserved_in_image_content_for_claude():
"""Cache control should be preserved in image content for Claude models."""
anthropic_messages = [

View file

@ -645,6 +645,85 @@ async def test_async_anthropic_messages_handler_header_priority():
assert captured_headers["X-Provider-Only"] == "keep-this-too"
@pytest.mark.asyncio
async def test_async_anthropic_messages_handler_drops_top_level_and_nested_params():
"""
Regression for LIT-3988 / GitHub #25931: on the /v1/messages path,
additional_drop_params must strip plain top-level keys (e.g. `thinking`,
`context_management`) before the provider transform runs, not only nested
dotted paths. Bedrock rejects these fields, so leaving them in produces a 400.
"""
handler = BaseLLMHTTPHandler()
mock_config = Mock()
mock_config.validate_anthropic_messages_environment = Mock(
return_value=({"x-api-key": "test-key"}, "https://api.anthropic.com")
)
captured = {}
def capture_transform(*args, **kwargs):
captured["optional_params"] = kwargs["anthropic_messages_optional_request_params"]
return {"model": "claude-opus-4-7", "messages": []}
mock_config.transform_anthropic_messages_request = capture_transform
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "Hello!"}],
"model": "claude-opus-4-7",
"stop_reason": "end_turn",
}
mock_client.post = AsyncMock(return_value=mock_response)
mock_logging_obj = Mock()
mock_logging_obj.update_from_kwargs = Mock()
mock_logging_obj.model_call_details = {}
mock_logging_obj.stream = False
optional_params = {
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"context_management": {"edits": [{"type": "clear_thinking_20251015"}]},
"metadata": {"user_id": "u1", "drop_me": "x"},
}
with patch(
"litellm.litellm_core_utils.get_provider_specific_headers.ProviderSpecificHeaderUtils.get_provider_specific_headers"
) as mock_provider_headers:
mock_provider_headers.return_value = None
try:
await handler.async_anthropic_messages_handler(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_provider_config=mock_config,
anthropic_messages_optional_request_params=optional_params,
custom_llm_provider="bedrock",
litellm_params=GenericLiteLLMParams(
additional_drop_params=[
"thinking",
"context_management",
"metadata.drop_me",
]
),
logging_obj=mock_logging_obj,
client=mock_client,
)
except Exception:
pass # drop runs before the mocked sign_request; the capture is what we assert on
transformed = captured["optional_params"]
assert "thinking" not in transformed
assert "context_management" not in transformed
assert transformed["max_tokens"] == 1024
assert transformed["metadata"] == {"user_id": "u1"}
def test_google_genai_streaming_hidden_params_model_info_and_router_fallback():
logging_obj = Mock()
logging_obj.get_router_model_id = Mock(return_value="router-model-id")