fix(utils): keep nested thinking when dropping top-level thinking

Bare additional_drop_params keys should only remove request-level
params. Conversation history and tool payloads that reuse the same
field name must stay intact so Anthropic fallback routing does not 400.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Huanyi Xie 2026-08-20 14:01:39 +03:00
parent 6d47468dae
commit 732664508f
6 changed files with 186 additions and 12 deletions

View file

@ -394,6 +394,7 @@ UTILS_MODULE_NAMES: Final = (
"process_response_headers",
"delete_nested_value",
"is_nested_path",
"apply_additional_drop_params",
"_get_base_model_from_litellm_call_metadata",
"get_litellm_params",
"_ensure_extra_body_is_safe",
@ -1354,6 +1355,10 @@ _UTILS_MODULE_IMPORT_MAP: Final = {
"litellm.litellm_core_utils.dot_notation_indexing",
"is_nested_path",
),
"apply_additional_drop_params": (
"litellm.litellm_core_utils.dot_notation_indexing",
"apply_additional_drop_params",
),
"_get_base_model_from_litellm_call_metadata": (
"litellm.litellm_core_utils.get_litellm_params",
"_get_base_model_from_litellm_call_metadata",

View file

@ -237,3 +237,49 @@ def is_nested_path(path: str) -> bool:
Returns True if path contains '.' or '[' (array notation).
"""
return "." in path or "[" in path
_PAYLOAD_KEYS_EXCLUDED_FROM_BARE_DROP: Final = frozenset({"messages", "input"})
def apply_additional_drop_params(
data: dict[str, Any],
paths: list[str],
) -> dict[str, Any]:
"""Apply ``additional_drop_params`` without touching conversation payload.
A bare key such as ``thinking`` is removed from the top-level request dict
only. Nested JSONPath entries such as ``tools[*].input_examples`` still
delete at that explicit path. ``messages`` and ``input`` are held aside for
the duration of a bare drop so a param name that also appears inside a
content block cannot strip user data. An explicit path that starts with
``messages`` or ``input`` is still honored.
"""
import copy
cloned: Final = copy.deepcopy(data)
stashed: Final = {key: cloned[key] for key in _PAYLOAD_KEYS_EXCLUDED_FROM_BARE_DROP if key in cloned}
dropped: dict[str, Any] = {key: value for key, value in cloned.items() if key not in stashed}
payload: dict[str, Any] = dict(stashed)
for path in paths:
if not isinstance(path, str):
continue
if is_nested_path(path):
segments: Final = _parse_path_segments(path)
first_segment: Final = segments[0] if segments else ""
if first_segment in payload:
wrapped: Final = {first_segment: payload[first_segment]}
updated: Final = delete_nested_value(wrapped, path)
payload = {
**payload,
first_segment: updated[first_segment],
} # rebind-ok: rebuild payload after explicit nested drop
else:
dropped = delete_nested_value(dropped, path) # rebind-ok: each nested path returns a new dict
elif path not in _PAYLOAD_KEYS_EXCLUDED_FROM_BARE_DROP:
dropped = {
key: value for key, value in dropped.items() if key != path
} # rebind-ok: bare drop rebuilds without the key
return {**dropped, **payload}

View file

@ -2111,12 +2111,13 @@ class BaseLLMHTTPHandler:
additional_drop_params: Final[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
from litellm.litellm_core_utils.dot_notation_indexing import apply_additional_drop_params
for path in additional_drop_params:
anthropic_messages_optional_request_params = delete_nested_value(
anthropic_messages_optional_request_params, path
anthropic_messages_optional_request_params = (
apply_additional_drop_params( # rebind-ok: drop returns a new params dict
anthropic_messages_optional_request_params, additional_drop_params
)
)
# Prepare request body
request_body: Final = anthropic_messages_provider_config.transform_anthropic_messages_request(

View file

@ -268,6 +268,7 @@ if TYPE_CHECKING:
)
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.dot_notation_indexing import (
apply_additional_drop_params,
delete_nested_value,
is_nested_path,
)
@ -4522,13 +4523,13 @@ def get_optional_params(
allowed_openai_params=allowed_openai_params,
)
# Apply nested drops from additional_drop_params
# Apply additional_drop_params. Bare keys stay top-level only; nested JSONPath
# still deletes at the explicit path. Conversation payload keys are not walked.
if additional_drop_params:
is_nested_path: Final = getattr(sys.modules[__name__], "is_nested_path")
delete_nested_value: Final = getattr(sys.modules[__name__], "delete_nested_value")
nested_paths: Final = [p for p in additional_drop_params if is_nested_path(p)]
for path in nested_paths:
optional_params = delete_nested_value(optional_params, path)
apply_additional_drop_params_fn: Final = getattr(sys.modules[__name__], "apply_additional_drop_params")
optional_params = apply_additional_drop_params_fn( # rebind-ok: drop returns a new params dict
optional_params, additional_drop_params
)
return optional_params

View file

@ -895,7 +895,8 @@ async def test_async_anthropic_messages_handler_drops_top_level_and_nested_param
def capture_transform(*args, **kwargs):
captured["optional_params"] = kwargs["anthropic_messages_optional_request_params"]
return {"model": "claude-opus-4-7", "messages": []}
captured["messages"] = kwargs["messages"]
return {"model": "claude-opus-4-7", "messages": kwargs["messages"]}
mock_config.transform_anthropic_messages_request = capture_transform
@ -923,6 +924,17 @@ async def test_async_anthropic_messages_handler_drops_top_level_and_nested_param
"context_management": {"edits": [{"type": "clear_thinking_20251015"}]},
"metadata": {"user_id": "u1", "drop_me": "x"},
}
history_messages = [
{"role": "user", "content": "What is 2+2?"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "keep me", "signature": "sig"},
{"type": "text", "text": "4"},
],
},
{"role": "user", "content": "And 3+3?"},
]
with patch(
"litellm.litellm_core_utils.get_provider_specific_headers.ProviderSpecificHeaderUtils.get_provider_specific_headers"
@ -931,7 +943,7 @@ async def test_async_anthropic_messages_handler_drops_top_level_and_nested_param
try:
await handler.async_anthropic_messages_handler(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "Hello"}],
messages=history_messages,
anthropic_messages_provider_config=mock_config,
anthropic_messages_optional_request_params=optional_params,
custom_llm_provider="bedrock",
@ -953,6 +965,8 @@ async def test_async_anthropic_messages_handler_drops_top_level_and_nested_param
assert "context_management" not in transformed
assert transformed["max_tokens"] == 1024
assert transformed["metadata"] == {"user_id": "u1"}
# Bare "thinking" must not blank conversation-history thinking blocks.
assert captured["messages"][1]["content"][0]["thinking"] == "keep me"
def test_google_genai_streaming_hidden_params_model_info_and_router_fallback():

View file

@ -348,4 +348,111 @@ class TestComplexNestedPatterns:
assert "remove_me" in data["tools"][0]["configs"][1]
class TestBareDropDoesNotStripMessagePayload:
"""Regression for GitHub #37479.
``additional_drop_params=["thinking"]`` must drop the top-level request
param only. Conversation history that already contains thinking content
blocks must keep the ``thinking`` field, otherwise Anthropic rejects the
fallback request with "each thinking block must contain thinking".
"""
_HISTORY_WITH_THINKING: dict = {
"thinking": {"type": "enabled", "budget_tokens": 2048},
"temperature": 0.2,
"messages": [
{"role": "user", "content": "What is 2+2?"},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "keep me",
"signature": "abc",
},
{"type": "text", "text": "4"},
],
},
],
"tools": [{"name": "search", "input_examples": [{"q": "x"}]}],
}
def test_bare_thinking_keeps_message_content_thinking(self):
from litellm.litellm_core_utils.dot_notation_indexing import (
apply_additional_drop_params,
)
result = apply_additional_drop_params(self._HISTORY_WITH_THINKING, ["thinking"])
assert "thinking" not in result
assert result["temperature"] == 0.2
assert result["messages"][1]["content"][0]["thinking"] == "keep me"
assert result["messages"][1]["content"][0]["type"] == "thinking"
def test_bare_thinking_does_not_strip_tool_payload_fields_named_thinking(self):
from litellm.litellm_core_utils.dot_notation_indexing import (
apply_additional_drop_params,
)
payload = {
"thinking": {"type": "enabled"},
"tools": [{"name": "t", "thinking": "keep tool field"}],
}
result = apply_additional_drop_params(payload, ["thinking"])
assert "thinking" not in result
assert result["tools"][0]["thinking"] == "keep tool field"
def test_explicit_nested_path_still_drops(self):
from litellm.litellm_core_utils.dot_notation_indexing import (
apply_additional_drop_params,
)
result = apply_additional_drop_params(
self._HISTORY_WITH_THINKING, ["tools[*].input_examples"]
)
assert "input_examples" not in result["tools"][0]
assert result["thinking"]["type"] == "enabled"
assert result["messages"][1]["content"][0]["thinking"] == "keep me"
def test_explicit_messages_path_can_still_drop_nested_field(self):
from litellm.litellm_core_utils.dot_notation_indexing import (
apply_additional_drop_params,
)
result = apply_additional_drop_params(
self._HISTORY_WITH_THINKING, ["messages[*].content[*].thinking"]
)
assert "thinking" in result
assert "thinking" not in result["messages"][1]["content"][0]
assert result["messages"][1]["content"][0]["type"] == "thinking"
def test_get_optional_params_does_not_strip_history_thinking(self):
from litellm.utils import get_optional_params
messages = [
{"role": "user", "content": "What is 2+2?"},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "keep me",
"signature": "abc",
},
{"type": "text", "text": "4"},
],
},
]
optional_params = get_optional_params(
model="claude-sonnet-4-5-20250929",
custom_llm_provider="anthropic",
messages=messages,
thinking={"type": "enabled", "budget_tokens": 1024},
additional_drop_params=["thinking"],
max_tokens=100,
)
assert "thinking" not in optional_params
assert messages[1]["content"][0]["thinking"] == "keep me"
# Phase 1 tests - validates core functionality and complex patterns