From c2fd9b3ca4305481e72f8803a9f8e09aa800313d Mon Sep 17 00:00:00 2001 From: Samarth Maganahalli Date: Thu, 9 Jul 2026 15:28:44 -0700 Subject: [PATCH] fix(anthropic): strip thinking_blocks for non-Anthropic backends The Anthropic /v1/messages -> OpenAI chat-completions pass-through adapter (translate_anthropic_messages_to_openai) attaches the Anthropic-specific thinking_blocks field to assistant messages unconditionally. Non-Anthropic OpenAI-compatible backends reject it: on multi-turn conversations, models like GLM behind an OpenAI-compatible endpoint fail with 400 invalid_request_error: Extra inputs are not permitted, field: 'messages[1].thinking_blocks' This breaks any multi-turn conversation once an earlier assistant turn carried reasoning. Verified directly against an OpenAI-compatible GLM endpoint (bypassing litellm): - assistant turn with thinking_blocks -> 400 (field rejected) - assistant turn with reasoning_content -> 200 OK (the model consumes it and reasons over the prior turn) So the fix is to convert, not just drop: for non-Anthropic backends, strip the raw thinking_blocks and set the OpenAI-style reasoning_content string (concatenating the unredacted thinking blocks; redacted blocks carry no readable text and are dropped). Gate the thinking_blocks attachment on is_anthropic_claude_model or is_bedrock_arn_model, the same pair of checks already used together elsewhere in this file (e.g. for cache_control). Anthropic Claude backends (anthropic/*, bedrock *anthropic*, vertex *claude*, and Bedrock ARNs such as Application Inference Profiles that point at Claude) keep thinking_blocks and their signed signatures unchanged. Everyone else gets reasoning_content instead. When the target model is unknown (None) the prior behaviour is preserved (blocks kept), so no existing caller changes. This is the complete form of the half-fixes in #27947 and #28258, both of which only add reasoning_content and leave thinking_blocks attached, so they do not resolve the 400. Closes #27946. --- .../adapters/transformation.py | 32 ++++++- ...al_pass_through_adapters_transformation.py | 86 +++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 4c981dd36b30..1abcc783c9e8 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -582,15 +582,43 @@ def translate_anthropic_messages_to_openai( else: assistant_content = assistant_message_str + # Gate ``thinking_blocks`` on the target backend. Non-Anthropic + # OpenAI-compatible backends (e.g. Fireworks, DeepSeek) reject + # the Anthropic-specific ``thinking_blocks`` field with "Extra + # inputs are not permitted", breaking multi-turn conversations. + # Only Anthropic Claude backends (including Bedrock ARNs that + # point at Claude) understand the field and its signed + # signatures. For everyone else, drop ``thinking_blocks`` and + # convert to the OpenAI-style ``reasoning_content`` string, + # which those backends accept and consume. When the model is + # unknown (``None``) we conservatively preserve the field to + # keep the prior behaviour. See #27946. + preserve_thinking_blocks = ( + model is None or self.is_anthropic_claude_model(model) or self.is_bedrock_arn_model(model) + ) assistant_message = ChatCompletionAssistantMessage( role="assistant", content=assistant_content, - thinking_blocks=(thinking_blocks if len(thinking_blocks) > 0 else None), + thinking_blocks=( + thinking_blocks if (preserve_thinking_blocks and len(thinking_blocks) > 0) else None + ), ) if len(tool_calls) > 0: assistant_message["tool_calls"] = tool_calls # type: ignore if len(thinking_blocks) > 0: - assistant_message["thinking_blocks"] = thinking_blocks # type: ignore + if preserve_thinking_blocks: + assistant_message["thinking_blocks"] = thinking_blocks # type: ignore + else: + # Concatenate every unredacted thinking block. Redacted + # blocks carry no readable text (only encrypted ``data``) + # and are dropped. + reasoning_content = "".join( + block.get("thinking", "") # type: ignore[union-attr] + for block in thinking_blocks + if block.get("type") == "thinking" + ) + if reasoning_content: + assistant_message["reasoning_content"] = reasoning_content # type: ignore new_messages.append(assistant_message) return new_messages diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index f9c55db72b57..ad8c70df3b3f 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -345,6 +345,92 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks(): assert result[1]["tool_calls"][0]["id"] == "toolu_01234" +def _thinking_and_redacted_messages(): + return [ + AnthropicMessagesUserMessageParam( + role="user", + content=[{"type": "text", "text": "What is 2+2?"}], + ), + AnthopicMessagesAssistantMessageParam( + role="assistant", + content=[ + { + "type": "thinking", + "thinking": "The user asks 2+2.", + "signature": "sig123", + }, + { + "type": "redacted_thinking", + "data": "REDACTED", + }, + { + "type": "thinking", + "thinking": " It is 4.", + "signature": "sig456", + }, + {"type": "text", "text": "4"}, + ], + ), + AnthropicMessagesUserMessageParam( + role="user", + content=[{"type": "text", "text": "Now multiply that by 3."}], + ), + ] + + +@pytest.mark.parametrize( + "model", ["glm-4.6", "azure/my-glm-deployment", "deepseek/deepseek-reasoner"] +) +def test_translate_anthropic_messages_to_openai_strips_thinking_blocks_for_non_anthropic( + model, +): + """Non-Anthropic backends reject the Anthropic-specific ``thinking_blocks`` + field ("Extra inputs are not permitted"). The adapter must drop it and + convert the unredacted blocks to a single ``reasoning_content`` string.""" + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai( + messages=_thinking_and_redacted_messages(), model=model + ) + + assistant = result[1] + assert assistant["role"] == "assistant" + # raw thinking_blocks must NOT be forwarded to a non-Anthropic backend. + # The key may be present as ``None`` (the constructor default), which + # litellm strips before serializing the request; what matters is that the + # actual blocks are not attached. + assert assistant.get("thinking_blocks") is None + # unredacted blocks are concatenated; the redacted block (no text) is dropped + assert assistant["reasoning_content"] == "The user asks 2+2. It is 4." + + +@pytest.mark.parametrize( + "model", + [ + "claude-sonnet-4", + "anthropic/claude-3-5-sonnet", + "vertex_ai/claude-opus", + "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile", + ], +) +def test_translate_anthropic_messages_to_openai_preserves_thinking_blocks_for_anthropic( + model, +): + """Anthropic Claude backends understand ``thinking_blocks`` (and their + signed signatures), so the field must be preserved unchanged and + ``reasoning_content`` must not be added.""" + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_messages_to_openai( + messages=_thinking_and_redacted_messages(), model=model + ) + + assistant = result[1] + assert "thinking_blocks" in assistant + assert len(assistant["thinking_blocks"]) == 3 + assert "reasoning_content" not in assistant + + def test_translate_anthropic_messages_to_openai_tool_message_placement(): """Test that tool result messages are placed before user messages in the conversation order."""