Skip to content

Commit b243218

Browse files
authored
feat: add thinking and cleaned_content properties to Message class (#65)
* feat: add thinking and cleaned_content properties to Message class Adds two new properties for handling models that include reasoning traces: - thinking: extracts content inside <think> tags - cleaned_content: returns content with <think> tags removed Useful for models like Qwen3, DeepSeek R1, and others that include chain-of-thought reasoning in their responses. * docs: add documentation for thinking and cleaned_content properties - README.md: Added example in LLM Responses section - docs/capabilities/llm.md: Added Handling Reasoning Traces section - CLAUDE.md: Added Message Thinking Properties documentation
1 parent a2ba7a7 commit b243218

File tree

8 files changed

+318
-3
lines changed

8 files changed

+318
-3
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.15.0] - 2026-01-16
9+
10+
### Added
11+
12+
- **Message Thinking Properties** - Added `thinking` and `cleaned_content` properties to `Message` class
13+
- `thinking`: Extracts content inside `<think>` tags (reasoning trace from models like Qwen3, DeepSeek R1)
14+
- `cleaned_content`: Returns content with `<think>` tags removed (actual response)
15+
- Multiple `<think>` blocks are concatenated
16+
- Returns `None` for `thinking` if no tags present
17+
818
## [2.14.1] - 2026-01-16
919

1020
### Fixed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,24 @@ async for chunk in model.achat_complete(messages):
323323
print(chunk.choices[0].delta.content, end="", flush=True)
324324
```
325325

326+
#### Handling Reasoning Traces
327+
328+
Some models (like Qwen3, DeepSeek R1) include chain-of-thought reasoning in `<think>` tags. The `Message` class provides convenient properties to handle this:
329+
330+
```python
331+
response = model.chat_complete(messages)
332+
msg = response.choices[0].message
333+
334+
# Full response including reasoning
335+
msg.content # "<think>Let me analyze...</think>\n\n{\"answer\": 42}"
336+
337+
# Just the reasoning (returns None if no <think> tags)
338+
msg.thinking # "Let me analyze..."
339+
340+
# Just the actual response (with <think> tags removed)
341+
msg.cleaned_content # "{\"answer\": 42}"
342+
```
343+
326344
### Embedding Responses
327345

328346
```python

docs/capabilities/llm.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,26 @@ chunk = next(model.chat_complete(messages, stream=True))
133133
chunk.choices[0].delta.content # Incremental content
134134
```
135135

136+
### Handling Reasoning Traces
137+
138+
Some models (like Qwen3, DeepSeek R1) include chain-of-thought reasoning in `<think>` tags. The `Message` class provides convenient properties to parse these:
139+
140+
```python
141+
response = model.chat_complete(messages)
142+
msg = response.choices[0].message
143+
144+
# Full response with reasoning
145+
msg.content # "<think>Let me analyze...</think>\n\n42"
146+
147+
# Just the reasoning trace (returns None if no <think> tags)
148+
msg.thinking # "Let me analyze..."
149+
150+
# Just the actual answer (with <think> tags removed)
151+
msg.cleaned_content # "42"
152+
```
153+
154+
Multiple `<think>` blocks are concatenated. If the response has no `<think>` tags, `thinking` returns `None` and `cleaned_content` returns the full content unchanged.
155+
136156
## Structured Output
137157

138158
Request JSON-formatted responses (where supported):
@@ -264,6 +284,27 @@ asyncio.run(main())
264284

265285
See [Resource Management](../advanced/connection-resource-management.md) for more details.
266286

287+
### Handling Reasoning Traces
288+
289+
```python
290+
# Works with models that include <think> tags (Qwen3, DeepSeek R1, etc.)
291+
model = AIFactory.create_language(
292+
"openai-compatible", "qwen/qwen3-4b",
293+
config={"base_url": "http://localhost:1234/v1"}
294+
)
295+
296+
messages = [{"role": "user", "content": "What is 15 * 24?"}]
297+
response = model.chat_complete(messages)
298+
msg = response.choices[0].message
299+
300+
# Get just the answer, without the reasoning
301+
print(msg.cleaned_content) # "360"
302+
303+
# Or inspect the reasoning for debugging
304+
if msg.thinking:
305+
print(f"Model's reasoning: {msg.thinking}")
306+
```
307+
267308
## See Also
268309

269310
- [Provider Setup Guides](../providers/README.md)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "esperanto"
3-
version = "2.14.1"
3+
version = "2.15.0"
44
description = "A light-weight, production-ready, unified interface for various AI model providers"
55
authors = [
66
{ name = "LUIS NOVO", email = "lfnovo@gmail.com" }

src/esperanto/common_types/CLAUDE.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ All providers convert their API responses to Esperanto's common types:
4444

4545
### Message Structure
4646

47-
Chat messages follow OpenAI-style format (response.py:31):
47+
Chat messages follow OpenAI-style format (response.py:34):
4848

4949
```python
5050
Message(
@@ -55,6 +55,27 @@ Message(
5555
)
5656
```
5757

58+
### Message Thinking Properties
59+
60+
The `Message` class provides properties for handling models that include reasoning traces (like Qwen3, DeepSeek R1):
61+
62+
- **`thinking`**: Extracts content inside `<think>` tags (returns `None` if no tags)
63+
- **`cleaned_content`**: Returns content with `<think>` tags removed
64+
65+
```python
66+
# Response from a model with reasoning traces
67+
msg = Message(
68+
content="<think>Let me analyze this...</think>\n\n{\"answer\": 42}",
69+
role="assistant"
70+
)
71+
72+
msg.content # "<think>Let me analyze this...</think>\n\n{\"answer\": 42}"
73+
msg.thinking # "Let me analyze this..."
74+
msg.cleaned_content # "{\"answer\": 42}"
75+
```
76+
77+
Multiple `<think>` blocks are concatenated with `\n\n`. If content has no `<think>` tags, `thinking` returns `None` and `cleaned_content` returns the full content.
78+
5879
### Usage Tracking
5980

6081
Token usage is standardized in `Usage` class (response.py:19):
@@ -239,3 +260,27 @@ print(msg["content"]) # "Hello"
239260
# Convert to dict
240261
msg_dict = msg.model_dump() # {"content": "Hello", "role": "user", ...}
241262
```
263+
264+
### Handling Reasoning Traces
265+
266+
```python
267+
from esperanto import AIFactory
268+
269+
# Models like Qwen3, DeepSeek R1 include <think> tags
270+
model = AIFactory.create_language("openai-compatible", "qwen/qwen3-4b", config={...})
271+
response = model.chat_complete([{"role": "user", "content": "What is 2+2?"}])
272+
273+
msg = response.choices[0].message
274+
275+
# Full response with reasoning
276+
print(msg.content)
277+
# "<think>I need to add 2 and 2...</think>\n\n4"
278+
279+
# Just the reasoning (useful for debugging/logging)
280+
print(msg.thinking)
281+
# "I need to add 2 and 2..."
282+
283+
# Just the answer (useful for parsing/display)
284+
print(msg.cleaned_content)
285+
# "4"
286+
```

src/esperanto/common_types/response.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""Response types for Esperanto."""
22

3+
import re
34
from typing import Any, Dict, List, Optional, Union
45

56
from pydantic import BaseModel, ConfigDict, Field, model_validator
67

8+
# Regex pattern to match <think>...</think> blocks (including multiline)
9+
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
10+
711

812
def to_dict(obj: Any) -> Dict[str, Any]:
913
"""Convert an object to a dictionary."""
@@ -50,6 +54,46 @@ def __getitem__(self, key: str) -> Any:
5054
"""Enable dict-like access for backward compatibility."""
5155
return getattr(self, key)
5256

57+
@property
58+
def thinking(self) -> Optional[str]:
59+
"""Extract content inside <think> tags (reasoning trace).
60+
61+
Returns the concatenated content of all <think>...</think> blocks
62+
in the message. Returns None if no thinking tags are present or
63+
if all thinking blocks are empty.
64+
65+
This is useful for models like Qwen3, DeepSeek R1, and others that
66+
include chain-of-thought reasoning in their responses.
67+
"""
68+
if not self.content:
69+
return None
70+
matches = _THINK_PATTERN.findall(self.content)
71+
if not matches:
72+
return None
73+
# Concatenate all thinking blocks, stripping whitespace
74+
non_empty = [match.strip() for match in matches if match.strip()]
75+
if not non_empty:
76+
return None
77+
return "\n\n".join(non_empty)
78+
79+
@property
80+
def cleaned_content(self) -> str:
81+
"""Get content with <think> tags removed (actual response).
82+
83+
Returns the message content with all <think>...</think> blocks
84+
removed. If there are no thinking tags, returns the original content.
85+
86+
This is useful for getting the actual response from models that
87+
include chain-of-thought reasoning in their responses.
88+
"""
89+
if not self.content:
90+
return ""
91+
# Remove all <think>...</think> blocks and clean up whitespace
92+
cleaned = _THINK_PATTERN.sub("", self.content)
93+
# Clean up extra whitespace/newlines left behind
94+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
95+
return cleaned.strip()
96+
5397
@model_validator(mode="before")
5498
@classmethod
5599
def convert_mock_content(cls, data: Any) -> Any:

tests/test_message_thinking.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Tests for Message.thinking and Message.cleaned_content properties."""
2+
3+
import pytest
4+
5+
from esperanto.common_types import Message
6+
7+
8+
class TestMessageThinkingProperty:
9+
"""Tests for the thinking property."""
10+
11+
def test_thinking_extracts_single_block(self):
12+
"""Test extracting content from a single think block."""
13+
content = "<think>Let me think about this.</think>\n\nThe answer is 42."
14+
msg = Message(content=content, role="assistant")
15+
assert msg.thinking == "Let me think about this."
16+
17+
def test_thinking_extracts_multiline_block(self):
18+
"""Test extracting multiline content from think block."""
19+
content = """<think>
20+
First, I need to consider X.
21+
Then, I should look at Y.
22+
Finally, Z is important.
23+
</think>
24+
25+
The answer is 42."""
26+
msg = Message(content=content, role="assistant")
27+
assert "First, I need to consider X." in msg.thinking
28+
assert "Then, I should look at Y." in msg.thinking
29+
assert "Finally, Z is important." in msg.thinking
30+
31+
def test_thinking_concatenates_multiple_blocks(self):
32+
"""Test that multiple think blocks are concatenated."""
33+
content = """<think>First thought</think>
34+
35+
Some response
36+
37+
<think>Second thought</think>
38+
39+
More response"""
40+
msg = Message(content=content, role="assistant")
41+
assert msg.thinking == "First thought\n\nSecond thought"
42+
43+
def test_thinking_returns_none_without_tags(self):
44+
"""Test that thinking returns None when no think tags present."""
45+
content = '{"name": "John", "age": 30}'
46+
msg = Message(content=content, role="assistant")
47+
assert msg.thinking is None
48+
49+
def test_thinking_returns_none_for_empty_content(self):
50+
"""Test that thinking returns None for None content."""
51+
msg = Message(content=None, role="assistant")
52+
assert msg.thinking is None
53+
54+
def test_thinking_returns_none_for_empty_think_tags(self):
55+
"""Test that thinking returns None when think tags are empty."""
56+
content = "<think>\n\n</think>\n\n{\"result\": 42}"
57+
msg = Message(content=content, role="assistant")
58+
assert msg.thinking is None
59+
60+
def test_thinking_strips_whitespace(self):
61+
"""Test that thinking content is stripped of leading/trailing whitespace."""
62+
content = "<think> \n Padded content \n </think>"
63+
msg = Message(content=content, role="assistant")
64+
assert msg.thinking == "Padded content"
65+
66+
67+
class TestMessageCleanedContentProperty:
68+
"""Tests for the cleaned_content property."""
69+
70+
def test_cleaned_content_removes_think_block(self):
71+
"""Test that cleaned_content removes think blocks."""
72+
content = "<think>Let me think.</think>\n\n{\"answer\": 42}"
73+
msg = Message(content=content, role="assistant")
74+
assert msg.cleaned_content == '{"answer": 42}'
75+
76+
def test_cleaned_content_removes_multiple_blocks(self):
77+
"""Test that cleaned_content removes multiple think blocks."""
78+
content = """<think>First thought</think>
79+
80+
Some response
81+
82+
<think>Second thought</think>
83+
84+
More response"""
85+
msg = Message(content=content, role="assistant")
86+
cleaned = msg.cleaned_content
87+
assert "<think>" not in cleaned
88+
assert "</think>" not in cleaned
89+
assert "First thought" not in cleaned
90+
assert "Second thought" not in cleaned
91+
assert "Some response" in cleaned
92+
assert "More response" in cleaned
93+
94+
def test_cleaned_content_returns_full_content_without_tags(self):
95+
"""Test that cleaned_content returns full content when no think tags."""
96+
content = '{"name": "John", "age": 30}'
97+
msg = Message(content=content, role="assistant")
98+
assert msg.cleaned_content == content
99+
100+
def test_cleaned_content_returns_empty_string_for_none(self):
101+
"""Test that cleaned_content returns empty string for None content."""
102+
msg = Message(content=None, role="assistant")
103+
assert msg.cleaned_content == ""
104+
105+
def test_cleaned_content_handles_empty_think_tags(self):
106+
"""Test that cleaned_content handles empty think tags correctly."""
107+
content = "<think>\n\n</think>\n\n{\"result\": 42}"
108+
msg = Message(content=content, role="assistant")
109+
assert msg.cleaned_content == '{"result": 42}'
110+
111+
def test_cleaned_content_cleans_excessive_newlines(self):
112+
"""Test that cleaned_content normalizes excessive newlines."""
113+
content = "<think>Thinking...</think>\n\n\n\n\nThe result"
114+
msg = Message(content=content, role="assistant")
115+
# Should not have more than 2 consecutive newlines
116+
assert "\n\n\n" not in msg.cleaned_content
117+
assert "The result" in msg.cleaned_content
118+
119+
120+
class TestMessageThinkingIntegration:
121+
"""Integration tests for thinking/cleaned_content with real-world examples."""
122+
123+
def test_qwen_style_response(self):
124+
"""Test parsing Qwen3-style response with think tags."""
125+
content = """<think>
126+
Okay, the user wants a JSON with name and age for a fictional person.
127+
Let me create something reasonable.
128+
Name: Elena Voss
129+
Age: 34
130+
</think>
131+
132+
{"name": "Elena Voss", "age": 34}"""
133+
msg = Message(content=content, role="assistant")
134+
135+
assert msg.thinking is not None
136+
assert "Elena Voss" in msg.thinking
137+
assert "Age: 34" in msg.thinking
138+
139+
assert msg.cleaned_content == '{"name": "Elena Voss", "age": 34}'
140+
assert "<think>" not in msg.cleaned_content
141+
142+
def test_empty_string_content(self):
143+
"""Test with empty string content."""
144+
msg = Message(content="", role="assistant")
145+
assert msg.thinking is None
146+
assert msg.cleaned_content == ""
147+
148+
def test_original_content_unchanged(self):
149+
"""Test that original content property is unchanged."""
150+
content = "<think>Reasoning</think>\n\nResult"
151+
msg = Message(content=content, role="assistant")
152+
153+
# Original content should be preserved
154+
assert msg.content == content
155+
# Properties should parse it differently
156+
assert msg.thinking == "Reasoning"
157+
assert msg.cleaned_content == "Result"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)