diff --git a/packages/core/src/repowise/core/providers/llm/anthropic.py b/packages/core/src/repowise/core/providers/llm/anthropic.py index 46f4e2292..68b6b3a5b 100644 --- a/packages/core/src/repowise/core/providers/llm/anthropic.py +++ b/packages/core/src/repowise/core/providers/llm/anthropic.py @@ -50,6 +50,22 @@ _DEFAULT_BASE_URL = "https://api.anthropic.com" +def _first_text_block(content: list[Any]) -> str: + """Return the text of the first text block in a Messages API response. + + The response content is not guaranteed to start with a text block: some + Anthropic-compatible endpoints (and Anthropic itself with extended + thinking enabled) emit reasoning/thinking blocks before the text block. + Indexing ``content[0].text`` blindly raises ``AttributeError`` in that + case, so scan for the first block that actually carries text. + """ + for block in content: + text = getattr(block, "text", None) + if text is not None: + return text + return "" + + def _anthropic_model_options( api_key: str, base_url: str, @@ -220,7 +236,7 @@ async def _generate_with_retry( cached = getattr(response.usage, "cache_read_input_tokens", 0) or 0 result = GeneratedResponse( - content=response.content[0].text, + content=_first_text_block(response.content), input_tokens=response.usage.input_tokens, output_tokens=response.usage.output_tokens, cached_tokens=cached, diff --git a/tests/unit/test_providers/test_anthropic_provider.py b/tests/unit/test_providers/test_anthropic_provider.py index 14d860d70..be8927f91 100644 --- a/tests/unit/test_providers/test_anthropic_provider.py +++ b/tests/unit/test_providers/test_anthropic_provider.py @@ -122,6 +122,33 @@ async def test_generate_returns_generated_response(): assert result.content == "Hello from Claude" +async def test_generate_skips_leading_non_text_block(): + """A thinking/reasoning block before the text block must not crash. + + Some Anthropic-compatible endpoints (and Anthropic with extended thinking) + return the text block after a thinking block, so ``content[0]`` has no + ``.text``. The provider should return the first block that carries text. + """ + provider = AnthropicProvider(api_key="sk-ant-test") + + # A ThinkingBlock-like object: spec without ``text`` so accessing + # ``.text`` raises AttributeError, matching the real crash. + thinking_block = MagicMock(spec=["type", "thinking"]) + thinking_block.type = "thinking" + text_block = MagicMock() + text_block.text = "Hello after thinking" + + mock_response = _make_mock_response() + mock_response.content = [thinking_block, text_block] + + with patch("anthropic.AsyncAnthropic") as mock_client: + mock_client.return_value.messages.create = AsyncMock(return_value=mock_response) + provider._client = mock_client.return_value + result = await provider.generate("sys", "user") + + assert result.content == "Hello after thinking" + + async def test_generate_token_counts_with_cache(): provider = AnthropicProvider(api_key="sk-ant-test") mock_response = _make_mock_response()