Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/core/src/repowise/core/providers/llm/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_providers/test_anthropic_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down