Skip to content

Commit d41b64a

Browse files
committed
fix(parser): clearer error when thinking block lacks signature
The bare KeyError: 'signature' doesn't tell users what's wrong. In practice this only happens with non-Anthropic upstreams (#339, #949). - Raise a clearer error pointing at the likely cause. - Add CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING to drop such blocks instead of crashing.
1 parent 694e4f3 commit d41b64a

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

src/claude_agent_sdk/_internal/message_parser.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Message parser for Claude Code SDK responses."""
22

33
import logging
4+
import os
45
from typing import Any
56

67
from .._errors import MessageParseError
@@ -131,6 +132,34 @@ def parse_message(data: dict[str, Any]) -> Message | None:
131132
case "text":
132133
content_blocks.append(TextBlock(text=block["text"]))
133134
case "thinking":
135+
if "signature" not in block:
136+
# Thinking blocks emitted by Anthropic models
137+
# always carry an encrypted ``signature`` used
138+
# for multi-turn round-tripping. A missing
139+
# ``signature`` almost always means the
140+
# upstream is an Anthropic-compatible (but
141+
# non-Anthropic) backend that doesn't generate
142+
# one. See issues #339, #949.
143+
if os.environ.get(
144+
"CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING"
145+
):
146+
logger.warning(
147+
"Dropping thinking block without "
148+
"'signature' field "
149+
"(CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING set)."
150+
)
151+
continue
152+
raise MessageParseError(
153+
"Assistant message contains a 'thinking' "
154+
"block without 'signature'. This usually "
155+
"means the upstream is not an Anthropic "
156+
"model — extended thinking from "
157+
"non-Anthropic backends is unsupported. "
158+
"Disable thinking on the upstream, or set "
159+
"CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING=1 "
160+
"to drop such blocks.",
161+
data,
162+
)
134163
content_blocks.append(
135164
ThinkingBlock(
136165
thinking=block["thinking"],

tests/test_message_parser.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,56 @@ def test_parse_assistant_message_with_thinking(self):
278278
assert isinstance(message.content[1], TextBlock)
279279
assert message.content[1].text == "Here's my response"
280280

281+
def test_parse_thinking_missing_signature_raises_clearer_error(self, monkeypatch):
282+
"""A thinking block without 'signature' raises an explanatory error.
283+
284+
Bare ``KeyError: 'signature'`` gives users no clue why this happened.
285+
The most common cause in the wild (see #339, #949) is an
286+
Anthropic-compatible upstream emitting thinking blocks without the
287+
encrypted signature, so the message points at that.
288+
"""
289+
monkeypatch.delenv("CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING", raising=False)
290+
data = {
291+
"type": "assistant",
292+
"message": {
293+
"content": [
294+
{"type": "thinking", "thinking": "no signature here"},
295+
],
296+
"model": "claude-opus-4-1-20250805",
297+
},
298+
}
299+
with pytest.raises(MessageParseError) as exc_info:
300+
parse_message(data)
301+
msg = str(exc_info.value)
302+
assert "signature" in msg
303+
assert (
304+
"non-Anthropic" in msg or "CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING" in msg
305+
)
306+
307+
def test_parse_thinking_missing_signature_skipped_with_env_var(self, monkeypatch):
308+
"""With the opt-out env var set, unsigned thinking blocks are dropped.
309+
310+
This lets users on non-Anthropic upstreams keep using the SDK without
311+
crashing on every assistant message. Other blocks in the message are
312+
preserved.
313+
"""
314+
monkeypatch.setenv("CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING", "1")
315+
data = {
316+
"type": "assistant",
317+
"message": {
318+
"content": [
319+
{"type": "thinking", "thinking": "no signature here"},
320+
{"type": "text", "text": "Here's my response"},
321+
],
322+
"model": "claude-opus-4-1-20250805",
323+
},
324+
}
325+
message = parse_message(data)
326+
assert isinstance(message, AssistantMessage)
327+
assert len(message.content) == 1
328+
assert isinstance(message.content[0], TextBlock)
329+
assert message.content[0].text == "Here's my response"
330+
281331
def test_parse_assistant_message_with_server_tool_use(self):
282332
"""server_tool_use blocks (e.g. advisor, web_search) are preserved.
283333

0 commit comments

Comments
 (0)