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
This commit is contained in:
Mateo Wang 2026-06-29 21:01:03 -07:00 • committed by GitHub
parent 6d5de74447
commit 884cdc1537
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 147 additions and 4 deletions

View file

@ -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,

View file

@ -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]]

View file

@ -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"""

View file

@ -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

View file

@ -21922,7 +21922,7 @@ export interface components {
[key: string]: unknown;
} | components["schemas"]["ChatCompletionCachedContent"] | null;
/** Signature */
signature?: string;
signature?: string | null;
/** Thinking */
thinking?: string;
/**