mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
6d5de74447
commit
884cdc1537
5 changed files with 147 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -21922,7 +21922,7 @@ export interface components {
|
|||
[key: string]: unknown;
|
||||
} | components["schemas"]["ChatCompletionCachedContent"] | null;
|
||||
/** Signature */
|
||||
signature?: string;
|
||||
signature?: string | null;
|
||||
/** Thinking */
|
||||
thinking?: string;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue