From 732664508fada94080ce6798438d2fc8c308e909 Mon Sep 17 00:00:00 2001 From: Huanyi Xie <74809554+xiehuanyi@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:01:39 +0300 Subject: [PATCH] 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 --- litellm/_lazy_imports_registry.py | 5 + .../dot_notation_indexing.py | 46 ++++++++ litellm/llms/custom_httpx/llm_http_handler.py | 9 +- litellm/utils.py | 13 ++- .../custom_httpx/test_llm_http_handler.py | 18 ++- tests/test_litellm/test_nested_drop_params.py | 107 ++++++++++++++++++ 6 files changed, 186 insertions(+), 12 deletions(-) diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 89c72acc06d..69523d847ae 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -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", diff --git a/litellm/litellm_core_utils/dot_notation_indexing.py b/litellm/litellm_core_utils/dot_notation_indexing.py index 80a27007329..bc3a399cd31 100644 --- a/litellm/litellm_core_utils/dot_notation_indexing.py +++ b/litellm/litellm_core_utils/dot_notation_indexing.py @@ -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} diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index cc522aed1ee..5a1d5e09c63 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -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( diff --git a/litellm/utils.py b/litellm/utils.py index d1b0cb882ac..2e140047782 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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 diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 9e9242137e6..3853210979d 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -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(): diff --git a/tests/test_litellm/test_nested_drop_params.py b/tests/test_litellm/test_nested_drop_params.py index bb1305ffde4..9c3fb6d4843 100644 --- a/tests/test_litellm/test_nested_drop_params.py +++ b/tests/test_litellm/test_nested_drop_params.py @@ -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