From 55822b03998ea3aab6165ade9fc9da7dfb58ba9b Mon Sep 17 00:00:00 2001 From: mdshzb04 <116161265+mdshzb04@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:19:29 +0100 Subject: [PATCH] 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) --- graphify/llm.py | 24 +++++++++++++++++-- tests/test_image_vision.py | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/graphify/llm.py b/graphify/llm.py index 46a4785..ff959e1 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -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 diff --git a/tests/test_image_vision.py b/tests/test_image_vision.py index 1071450..fa9b639 100644 --- a/tests/test_image_vision.py +++ b/tests/test_image_vision.py @@ -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):