From 884cdc1537de22ad3b37022d5f6a4364da689e4f Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:01:03 -0700 Subject: [PATCH] fix(anthropic): drop unsignable thinking blocks and allow null signature in logging (LIT-4007) (#31654) * fix(anthropic): drop unsignable thinking blocks and allow null signature in logging (LIT-4007) Open-source reasoning models (DeepSeek-R1 and distills, Qwen3/QwQ, IBM Granite 3.2 via vLLM/Ollama/OpenRouter/DeepSeek) return reasoning_content with no Anthropic-style signature, which LiteLLM represents as a thinking block with a null signature. Two failures resulted. First, ChatCompletionThinkingBlock.signature was a required str, so building the StandardLoggingObject raised a ValidationError on signature=None and the success log record was silently dropped while the request still returned 200; relaxing it to Optional[str] lets the log build. Second, replaying such a turn to a real Anthropic model forwarded the null-signature thinking block unchanged and Anthropic rejected it with 400 thinking.signature.str; since Anthropic verifies the signature cryptographically, a null, empty, or missing signature cannot be repaired, so anthropic_messages_pt now drops the unsignable thinking block while preserving the assistant text and keeping genuinely signed blocks. * style: use builtin generics for thinking-block filter helpers * fix(ui): regenerate schema.d.ts for nullable thinking-block signature --- .../prompt_templates/factory.py | 29 +++++++- litellm/types/llms/openai.py | 2 +- ...llm_core_utils_prompt_templates_factory.py | 73 +++++++++++++++++++ tests/test_litellm/types/test_types_utils.py | 45 ++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 5 files changed, 147 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 55900133de8..1f0df51d7de 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2319,6 +2319,26 @@ def sanitize_messages_for_tool_calling( return sanitized_messages +def _is_unsignable_thinking_block(block: object) -> bool: + """A `thinking` block that Anthropic cannot accept on input. + + Anthropic verifies the thinking signature cryptographically, so a block whose + signature is null, empty, or missing (e.g. from an open-source reasoning model) + is rejected with a 400 and must be dropped rather than blanked or repaired. + `redacted_thinking` blocks carry no signature and are always kept. + """ + if not isinstance(block, dict) or block.get("type") != "thinking": + return False + signature = block.get("signature") + return not (isinstance(signature, str) and len(signature) > 0) + + +def _drop_unsignable_thinking_blocks( + thinking_blocks: list[Union[ChatCompletionThinkingBlock, ChatCompletionRedactedThinkingBlock]], +) -> list[Union[ChatCompletionThinkingBlock, ChatCompletionRedactedThinkingBlock]]: + return [block for block in thinking_blocks if not _is_unsignable_thinking_block(block)] + + def anthropic_messages_pt( messages: List[AllMessageValues], model: str, @@ -2507,7 +2527,10 @@ def anthropic_messages_pt( # Add compaction blocks at the beginning of assistant content : https://platform.claude.com/docs/en/build-with-claude/compaction assistant_content.extend(_compaction_blocks) # type: ignore - thinking_blocks = assistant_content_block.get("thinking_blocks", None) + _raw_thinking_blocks = assistant_content_block.get("thinking_blocks", None) + thinking_blocks = ( + _drop_unsignable_thinking_blocks(_raw_thinking_blocks) if _raw_thinking_blocks is not None else None + ) # Check if tool_calls contain server tool calls (web search, etc.) # If so, we need to interleave thinking blocks with tool call groups @@ -2671,7 +2694,9 @@ def anthropic_messages_pt( thinking_block = cast(str, m.get("thinking", "")) text_block = cast(str, m.get("text", "")) if ( - m.get("type", "") == "thinking" and len(thinking_block) > 0 + m.get("type", "") == "thinking" + and len(thinking_block) > 0 + and not _is_unsignable_thinking_block(m) ): # don't pass empty text blocks. anthropic api raises errors. anthropic_message: Union[ ChatCompletionThinkingBlock, diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 42015f76442..4c656b32081 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -533,7 +533,7 @@ class ChatCompletionCachedContent(TypedDict): class ChatCompletionThinkingBlock(TypedDict, total=False): type: Required[Literal["thinking"]] thinking: str - signature: str + signature: Optional[str] cache_control: Optional[Union[dict, ChatCompletionCachedContent]] diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index c022303e9d6..864f685e7c9 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -181,6 +181,79 @@ def test_bedrock_converse_assistant_with_empty_thinking_block_and_tool_calls(): assert len(tool_use_blocks) == 2 +@pytest.mark.parametrize( + "thinking_block", + [ + {"type": "thinking", "thinking": "oss reasoning", "signature": None}, + {"type": "thinking", "thinking": "oss reasoning", "signature": ""}, + {"type": "thinking", "thinking": "oss reasoning"}, + ], + ids=["null_signature", "empty_signature", "missing_signature"], +) +def test_anthropic_messages_pt_drops_unsignable_thinking_block(thinking_block): + """Open-source reasoning models (DeepSeek-R1, Qwen, etc.) emit thinking blocks + with no Anthropic signature. Anthropic verifies the signature cryptographically, + so replaying a null/empty/missing-signature thinking block is rejected with + 400 ... thinking.signature.str: Input should be a valid string. + anthropic_messages_pt must drop the unsignable thinking block while preserving + the assistant's answer text. Regression for LIT-4007. + """ + messages = [ + {"role": "user", "content": "What is 2+2?"}, + { + "role": "assistant", + "content": "2+2 equals 4.", + "thinking_blocks": [thinking_block], + }, + {"role": "user", "content": "Now what is 3+3?"}, + ] + + result = anthropic_messages_pt( + messages=messages, model="claude-sonnet-4-6", llm_provider="anthropic" + ) + + assistant = next(m for m in result if m["role"] == "assistant") + content = assistant["content"] + assert all( + block.get("type") != "thinking" for block in content + ), f"unsignable thinking block must be dropped, got {content!r}" + assert any( + block.get("type") == "text" and block.get("text") == "2+2 equals 4." + for block in content + ), f"assistant answer text must be preserved, got {content!r}" + + +def test_anthropic_messages_pt_keeps_signed_thinking_block(): + """A genuine Anthropic round-trip still holds its original signature, so that + thinking block must be forwarded unchanged (we only drop unsignable blocks). + Regression for LIT-4007. + """ + signed_block = { + "type": "thinking", + "thinking": "genuine anthropic reasoning", + "signature": "ErcBCkgIValidSignatureBytes", + } + messages = [ + {"role": "user", "content": "What is 2+2?"}, + { + "role": "assistant", + "content": "2+2 equals 4.", + "thinking_blocks": [signed_block], + }, + {"role": "user", "content": "Now what is 3+3?"}, + ] + + result = anthropic_messages_pt( + messages=messages, model="claude-sonnet-4-6", llm_provider="anthropic" + ) + + assistant = next(m for m in result if m["role"] == "assistant") + thinking_blocks = [b for b in assistant["content"] if b.get("type") == "thinking"] + assert len(thinking_blocks) == 1 + assert thinking_blocks[0]["signature"] == "ErcBCkgIValidSignatureBytes" + assert thinking_blocks[0]["thinking"] == "genuine anthropic reasoning" + + def test_convert_to_azure_openai_messages(): """Test coverting image_url to azure_openai spec""" diff --git a/tests/test_litellm/types/test_types_utils.py b/tests/test_litellm/types/test_types_utils.py index fde71ae65f2..4147ce47ae5 100644 --- a/tests/test_litellm/types/test_types_utils.py +++ b/tests/test_litellm/types/test_types_utils.py @@ -371,3 +371,48 @@ def test_delta_maps_reasoning_to_reasoning_content(): # When neither is present, reasoning_content is not set (OpenAI spec) delta4 = Delta(content="hello") assert not hasattr(delta4, "reasoning_content") + + +def test_message_accepts_thinking_block_with_null_signature(): + """Open-source reasoning models (DeepSeek-R1, Qwen, etc.) emit thinking blocks + without an Anthropic-style signature. Message must accept signature=None so the + success-logging handler can build the StandardLoggingObject instead of silently + dropping the log record. Regression for LIT-4007. + """ + from litellm.types.utils import Choices, Message + + thinking_blocks = [ + {"type": "thinking", "thinking": "step by step reasoning", "signature": None} + ] + + message = Message( + content="the answer is 4", role="assistant", thinking_blocks=thinking_blocks + ) + assert message.thinking_blocks is not None + assert message.thinking_blocks[0]["signature"] is None + assert message.thinking_blocks[0]["thinking"] == "step by step reasoning" + + validated = Message.model_validate( + { + "role": "assistant", + "content": "the answer is 4", + "thinking_blocks": thinking_blocks, + } + ) + dumped = validated.model_dump() + assert dumped["thinking_blocks"][0]["signature"] is None + assert dumped["thinking_blocks"][0]["thinking"] == "step by step reasoning" + + choice = Choices.model_validate( + { + "finish_reason": "stop", + "index": 0, + "message": { + "role": "assistant", + "content": "the answer is 4", + "thinking_blocks": thinking_blocks, + }, + } + ) + assert choice.message.thinking_blocks is not None + assert choice.message.thinking_blocks[0]["signature"] is None diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index b3c2b44ee4e..7d5d617c824 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -21922,7 +21922,7 @@ export interface components { [key: string]: unknown; } | components["schemas"]["ChatCompletionCachedContent"] | null; /** Signature */ - signature?: string; + signature?: string | null; /** Thinking */ thinking?: string; /**