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:
mdshzb04
2026-08-18 22:19:29 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 97f2bb7540
commit 55822b0399
2 changed files with 69 additions and 2 deletions
+22 -2
View File
@@ -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
+47
View File
@@ -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):