mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
6d47468dae
commit
732664508f
6 changed files with 186 additions and 12 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue