fix(claude): read text blocks after a leading ThinkingBlock (#2697)
Extended thinking is on by default on current Claude models, so a response's content[0] is a ThinkingBlock and the old content[0].text raised AttributeError on the SDK claude backend. A shape-aware helper (mirroring the existing _bedrock_response_text) skips leading non-text blocks and returns the first text block; a thinking-only response falls back to the default. Only the two SDK claude call sites are touched; the claude-cli JSON-envelope path is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
97f2bb7540
commit
55822b0399
+22
-2
@@ -1065,6 +1065,26 @@ def _parse_llm_json(raw: str) -> dict:
|
||||
return {"nodes": [], "edges": [], "hyperedges": []}
|
||||
|
||||
|
||||
def _anthropic_response_text(content, default: str | None = None) -> str | None:
|
||||
"""Return the first Anthropic content block that carries text.
|
||||
|
||||
Current Claude models emit a ``ThinkingBlock`` ahead of the ``TextBlock``
|
||||
when extended thinking is enabled (including the default-on path where the
|
||||
thinking text is omitted). Indexing ``content[0]`` therefore raises or
|
||||
yields no text (#2697). Select on the block's type instead of its position.
|
||||
"""
|
||||
if not content:
|
||||
return default
|
||||
for block in content:
|
||||
block_type = getattr(block, "type", None)
|
||||
if block_type is not None and block_type != "text":
|
||||
continue
|
||||
text = getattr(block, "text", None)
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text
|
||||
return default
|
||||
|
||||
|
||||
def _bedrock_response_text(resp: dict, default: str = "") -> str:
|
||||
"""Return the first Converse content block that carries text.
|
||||
|
||||
@@ -1331,7 +1351,7 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int =
|
||||
system=_extraction_system(deep=deep_mode),
|
||||
messages=[{"role": "user", "content": _anthropic_content(user_message, images or [])}],
|
||||
)
|
||||
raw_content = resp.content[0].text if resp.content else None
|
||||
raw_content = _anthropic_response_text(resp.content)
|
||||
result = _parse_llm_json(raw_content or "{}")
|
||||
result["input_tokens"] = resp.usage.input_tokens if resp.usage else 0
|
||||
result["output_tokens"] = resp.usage.output_tokens if resp.usage else 0
|
||||
@@ -2602,7 +2622,7 @@ def _call_llm(
|
||||
u = getattr(resp, "usage", None)
|
||||
if u is not None:
|
||||
_rec(getattr(u, "input_tokens", 0), getattr(u, "output_tokens", 0))
|
||||
return resp.content[0].text if resp.content else ""
|
||||
return _anthropic_response_text(resp.content, default="")
|
||||
|
||||
if backend == "claude-cli":
|
||||
import platform, shutil, subprocess
|
||||
|
||||
@@ -401,6 +401,53 @@ def test_bedrock_response_text_tolerates_malformed_blocks():
|
||||
assert llm._bedrock_response_text(resp, default="{}") == _NODE_JSON
|
||||
|
||||
|
||||
def test_anthropic_response_text_single_text_block_unchanged():
|
||||
content = [SimpleNamespace(type="text", text=_NODE_JSON)]
|
||||
assert llm._anthropic_response_text(content) == _NODE_JSON
|
||||
|
||||
|
||||
def test_anthropic_response_text_skips_leading_thinking_block():
|
||||
content = [
|
||||
SimpleNamespace(type="thinking", thinking="planning"),
|
||||
SimpleNamespace(type="text", text=_NODE_JSON),
|
||||
]
|
||||
assert llm._anthropic_response_text(content, default="{}") == _NODE_JSON
|
||||
|
||||
|
||||
def test_anthropic_response_text_legacy_block_without_type():
|
||||
content = [SimpleNamespace(text=_NODE_JSON)]
|
||||
assert llm._anthropic_response_text(content) == _NODE_JSON
|
||||
|
||||
|
||||
def test_anthropic_response_text_falls_back_without_text():
|
||||
content = [SimpleNamespace(type="thinking", thinking="")]
|
||||
assert llm._anthropic_response_text(content, default="SENTINEL") == "SENTINEL"
|
||||
|
||||
|
||||
def test_call_claude_parses_thinking_model_response(tmp_path, monkeypatch):
|
||||
"""Extended-thinking models must not crash on content[0] being ThinkingBlock."""
|
||||
img, _, _ = _make_corpus(tmp_path)
|
||||
refs = llm._build_image_refs([img], tmp_path)
|
||||
|
||||
class _Messages:
|
||||
def create(self, **_kw):
|
||||
return SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(type="thinking", thinking=""),
|
||||
SimpleNamespace(type="text", text=_NODE_JSON),
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=5, output_tokens=7),
|
||||
stop_reason="end_turn",
|
||||
)
|
||||
|
||||
mod = types.ModuleType("anthropic")
|
||||
mod.Anthropic = lambda **_kw: SimpleNamespace(messages=_Messages())
|
||||
monkeypatch.setitem(sys.modules, "anthropic", mod)
|
||||
|
||||
result = llm._call_claude("k", "claude-opus-4-6", "CORPUS", images=refs)
|
||||
assert result["nodes"]
|
||||
|
||||
|
||||
def test_call_bedrock_parses_reasoning_model_response(monkeypatch):
|
||||
"""End-to-end: a reasoning-model response must not look hollow."""
|
||||
def _fake(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user