diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 574816c6..5ca00be9 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -1,6 +1,7 @@ """Message parser for Claude Code SDK responses.""" import logging +import os from typing import Any from .._errors import MessageParseError @@ -131,6 +132,34 @@ def parse_message(data: dict[str, Any]) -> Message | None: case "text": content_blocks.append(TextBlock(text=block["text"])) case "thinking": + if "signature" not in block: + # Thinking blocks emitted by Anthropic models + # always carry an encrypted ``signature`` used + # for multi-turn round-tripping. A missing + # ``signature`` almost always means the + # upstream is an Anthropic-compatible (but + # non-Anthropic) backend that doesn't generate + # one. See issues #339, #949. + if os.environ.get( + "CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING" + ): + logger.warning( + "Dropping thinking block without " + "'signature' field " + "(CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING set)." + ) + continue + raise MessageParseError( + "Assistant message contains a 'thinking' " + "block without 'signature'. This usually " + "means the upstream is not an Anthropic " + "model — extended thinking from " + "non-Anthropic backends is unsupported. " + "Disable thinking on the upstream, or set " + "CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING=1 " + "to drop such blocks.", + data, + ) content_blocks.append( ThinkingBlock( thinking=block["thinking"], diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 7ce2990c..ac9c733c 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -278,6 +278,56 @@ def test_parse_assistant_message_with_thinking(self): assert isinstance(message.content[1], TextBlock) assert message.content[1].text == "Here's my response" + def test_parse_thinking_missing_signature_raises_clearer_error(self, monkeypatch): + """A thinking block without 'signature' raises an explanatory error. + + Bare ``KeyError: 'signature'`` gives users no clue why this happened. + The most common cause in the wild (see #339, #949) is an + Anthropic-compatible upstream emitting thinking blocks without the + encrypted signature, so the message points at that. + """ + monkeypatch.delenv("CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING", raising=False) + data = { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "no signature here"}, + ], + "model": "claude-opus-4-1-20250805", + }, + } + with pytest.raises(MessageParseError) as exc_info: + parse_message(data) + msg = str(exc_info.value) + assert "signature" in msg + assert ( + "non-Anthropic" in msg or "CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING" in msg + ) + + def test_parse_thinking_missing_signature_skipped_with_env_var(self, monkeypatch): + """With the opt-out env var set, unsigned thinking blocks are dropped. + + This lets users on non-Anthropic upstreams keep using the SDK without + crashing on every assistant message. Other blocks in the message are + preserved. + """ + monkeypatch.setenv("CLAUDE_AGENT_SDK_SKIP_UNSIGNED_THINKING", "1") + data = { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "no signature here"}, + {"type": "text", "text": "Here's my response"}, + ], + "model": "claude-opus-4-1-20250805", + }, + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert len(message.content) == 1 + assert isinstance(message.content[0], TextBlock) + assert message.content[0].text == "Here's my response" + def test_parse_assistant_message_with_server_tool_use(self): """server_tool_use blocks (e.g. advisor, web_search) are preserved.