Skip to content

Commit bba7426

Browse files
committed
Fix AttributeError when Anthropic response leads with a thinking block
AnthropicClient.completion()/acompletion() indexed response.content[0] directly, assuming it was always the text block. When extended thinking is enabled, Anthropic returns a ThinkingBlock first, which has no .text attribute and crashes the call. Extract the first block with type=="text" instead, and raise a clear error if none is present.
1 parent 854e688 commit bba7426

2 files changed

Lines changed: 176 additions & 2 deletions

File tree

‎rlm/clients/anthropic.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
from rlm.core.types import ModelUsageSummary, UsageSummary
88

99

10+
def _extract_text(content: list[Any]) -> str:
11+
"""Return the first text block's content, skipping any thinking blocks
12+
that may precede it (e.g. when extended thinking is enabled)."""
13+
for block in content:
14+
if block.type == "text":
15+
return block.text
16+
raise ValueError("Anthropic response contained no text block")
17+
18+
1019
class AnthropicClient(BaseLM):
1120
"""
1221
LM Client for running models with the Anthropic API.
@@ -44,7 +53,7 @@ def completion(self, prompt: str | list[dict[str, Any]], model: str | None = Non
4453

4554
response = self.client.messages.create(**kwargs)
4655
self._track_cost(response, model)
47-
return response.content[0].text
56+
return _extract_text(response.content)
4857

4958
async def acompletion(
5059
self, prompt: str | list[dict[str, Any]], model: str | None = None
@@ -61,7 +70,7 @@ async def acompletion(
6170

6271
response = await self.async_client.messages.create(**kwargs)
6372
self._track_cost(response, model)
64-
return response.content[0].text
73+
return _extract_text(response.content)
6574

6675
def _prepare_messages(
6776
self, prompt: str | list[dict[str, Any]]

‎tests/clients/test_anthropic.py‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Tests for the Anthropic client."""
2+
3+
import os
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
from dotenv import load_dotenv
8+
9+
from rlm.clients.anthropic import AnthropicClient, _extract_text
10+
from rlm.core.types import ModelUsageSummary, UsageSummary
11+
12+
load_dotenv()
13+
14+
15+
def _text_block(text: str) -> MagicMock:
16+
block = MagicMock()
17+
block.type = "text"
18+
block.text = text
19+
return block
20+
21+
22+
def _thinking_block() -> MagicMock:
23+
block = MagicMock(spec=["type", "thinking"])
24+
block.type = "thinking"
25+
block.thinking = "reasoning..."
26+
return block
27+
28+
29+
class TestExtractText:
30+
"""Unit tests for the module-level _extract_text helper."""
31+
32+
def test_text_only(self):
33+
assert _extract_text([_text_block("hello")]) == "hello"
34+
35+
def test_thinking_then_text(self):
36+
content = [_thinking_block(), _text_block("hello")]
37+
assert _extract_text(content) == "hello"
38+
39+
def test_no_text_block_raises(self):
40+
with pytest.raises(ValueError, match="no text block"):
41+
_extract_text([_thinking_block()])
42+
43+
44+
class TestAnthropicClientUnit:
45+
"""Unit tests that don't require API calls."""
46+
47+
def test_init_with_api_key(self):
48+
with (
49+
patch("rlm.clients.anthropic.anthropic.Anthropic"),
50+
patch("rlm.clients.anthropic.anthropic.AsyncAnthropic"),
51+
):
52+
client = AnthropicClient(api_key="test-key", model_name="claude-sonnet-5")
53+
assert client.model_name == "claude-sonnet-5"
54+
55+
def test_completion_requires_model(self):
56+
with (
57+
patch("rlm.clients.anthropic.anthropic.Anthropic"),
58+
patch("rlm.clients.anthropic.anthropic.AsyncAnthropic"),
59+
):
60+
client = AnthropicClient(api_key="test-key", model_name=None)
61+
with pytest.raises(ValueError, match="Model name is required"):
62+
client.completion("Hello")
63+
64+
def test_completion_with_mocked_response(self):
65+
mock_response = MagicMock()
66+
mock_response.content = [_text_block("Hello from Claude!")]
67+
mock_response.usage.input_tokens = 10
68+
mock_response.usage.output_tokens = 5
69+
70+
with (
71+
patch("rlm.clients.anthropic.anthropic.Anthropic") as mock_client_class,
72+
patch("rlm.clients.anthropic.anthropic.AsyncAnthropic"),
73+
):
74+
mock_client = MagicMock()
75+
mock_client.messages.create.return_value = mock_response
76+
mock_client_class.return_value = mock_client
77+
78+
client = AnthropicClient(api_key="test-key", model_name="claude-sonnet-5")
79+
result = client.completion("Hello")
80+
81+
assert result == "Hello from Claude!"
82+
assert client.model_call_counts["claude-sonnet-5"] == 1
83+
assert client.model_input_tokens["claude-sonnet-5"] == 10
84+
assert client.model_output_tokens["claude-sonnet-5"] == 5
85+
86+
def test_completion_with_leading_thinking_block(self):
87+
"""Regression test: a ThinkingBlock preceding the text block must not crash."""
88+
mock_response = MagicMock()
89+
mock_response.content = [_thinking_block(), _text_block("Hello from Claude!")]
90+
mock_response.usage.input_tokens = 10
91+
mock_response.usage.output_tokens = 5
92+
93+
with (
94+
patch("rlm.clients.anthropic.anthropic.Anthropic") as mock_client_class,
95+
patch("rlm.clients.anthropic.anthropic.AsyncAnthropic"),
96+
):
97+
mock_client = MagicMock()
98+
mock_client.messages.create.return_value = mock_response
99+
mock_client_class.return_value = mock_client
100+
101+
client = AnthropicClient(api_key="test-key", model_name="claude-sonnet-5")
102+
result = client.completion("Hello")
103+
104+
assert result == "Hello from Claude!"
105+
106+
@pytest.mark.anyio
107+
async def test_acompletion_with_leading_thinking_block(self):
108+
mock_response = MagicMock()
109+
mock_response.content = [_thinking_block(), _text_block("Hello from Claude!")]
110+
mock_response.usage.input_tokens = 10
111+
mock_response.usage.output_tokens = 5
112+
113+
with (
114+
patch("rlm.clients.anthropic.anthropic.Anthropic"),
115+
patch("rlm.clients.anthropic.anthropic.AsyncAnthropic") as mock_async_client_class,
116+
):
117+
mock_async_client = MagicMock()
118+
119+
async def _create(**kwargs):
120+
return mock_response
121+
122+
mock_async_client.messages.create = _create
123+
mock_async_client_class.return_value = mock_async_client
124+
125+
client = AnthropicClient(api_key="test-key", model_name="claude-sonnet-5")
126+
result = await client.acompletion("Hello")
127+
128+
assert result == "Hello from Claude!"
129+
130+
def test_get_usage_summary_empty(self):
131+
with (
132+
patch("rlm.clients.anthropic.anthropic.Anthropic"),
133+
patch("rlm.clients.anthropic.anthropic.AsyncAnthropic"),
134+
):
135+
client = AnthropicClient(api_key="test-key", model_name="claude-sonnet-5")
136+
summary = client.get_usage_summary()
137+
assert isinstance(summary, UsageSummary)
138+
assert summary.model_usage_summaries == {}
139+
140+
def test_get_last_usage(self):
141+
with (
142+
patch("rlm.clients.anthropic.anthropic.Anthropic"),
143+
patch("rlm.clients.anthropic.anthropic.AsyncAnthropic"),
144+
):
145+
client = AnthropicClient(api_key="test-key", model_name="claude-sonnet-5")
146+
client.last_prompt_tokens = 100
147+
client.last_completion_tokens = 50
148+
usage = client.get_last_usage()
149+
assert isinstance(usage, ModelUsageSummary)
150+
assert usage.total_calls == 1
151+
assert usage.total_input_tokens == 100
152+
assert usage.total_output_tokens == 50
153+
154+
155+
class TestAnthropicClientIntegration:
156+
"""Integration tests that require a real API key."""
157+
158+
@pytest.mark.skipif(
159+
not os.environ.get("ANTHROPIC_API_KEY"),
160+
reason="ANTHROPIC_API_KEY not set",
161+
)
162+
def test_simple_completion(self):
163+
client = AnthropicClient(model_name="claude-sonnet-5")
164+
result = client.completion("What is 2+2? Reply with just the number.")
165+
assert "4" in result

0 commit comments

Comments
 (0)