Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Improvements

* `.stream()` and `.stream_async()` now emit `<thinking>` / `</thinking>` tag boundaries around thinking content. With `content="text"`, concatenating all chunks produces well-formed output with thinking delimited by a single tag pair. With `content="all"`, behavior is unchanged — typed `ContentThinking` objects are yielded without tag strings. (#294)
* `.stream()` and `.stream_async()` now emit `<thinking>` / `</thinking>` tag boundaries around thinking content in all stream modes. With `content="text"`, concatenating all chunks produces well-formed output with thinking delimited by a single tag pair. With `content="all"`, tag boundary strings are yielded alongside typed `ContentThinking` objects, so downstream consumers can detect thinking boundaries without type inspection. (#294, #297)
* Updated default models across all providers to current generation: (#292)
* Anthropic: `claude-sonnet-4-6`
* Bedrock: `us.anthropic.claude-sonnet-4-6`
Expand Down
18 changes: 6 additions & 12 deletions chatlas/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2681,13 +2681,11 @@ def emit(text: str | Content):
is_thinking = isinstance(content, ContentThinking)
if is_thinking and not inside_thinking:
emit("<thinking>\n")
if content_mode == "text":
yield "<thinking>\n"
yield "<thinking>\n"
inside_thinking = True
elif not is_thinking and inside_thinking:
emit("\n</thinking>\n\n")
if content_mode == "text":
yield "\n</thinking>\n\n"
yield "\n</thinking>\n\n"
Comment thread
cpsievert marked this conversation as resolved.
inside_thinking = False

emit(text)
Expand All @@ -2699,8 +2697,7 @@ def emit(text: str | Content):

if inside_thinking:
emit("\n</thinking>\n\n")
if content_mode == "text":
yield "\n</thinking>\n\n"
yield "\n</thinking>\n\n"

turn = self.provider.stream_turn(
result,
Expand Down Expand Up @@ -2804,13 +2801,11 @@ def emit(text: str | Content):
is_thinking = isinstance(content, ContentThinking)
if is_thinking and not inside_thinking:
emit("<thinking>\n")
if content_mode == "text":
yield "<thinking>\n"
yield "<thinking>\n"
inside_thinking = True
elif not is_thinking and inside_thinking:
emit("\n</thinking>\n\n")
if content_mode == "text":
yield "\n</thinking>\n\n"
yield "\n</thinking>\n\n"
inside_thinking = False

emit(text)
Expand All @@ -2822,8 +2817,7 @@ def emit(text: str | Content):

if inside_thinking:
emit("\n</thinking>\n\n")
if content_mode == "text":
yield "\n</thinking>\n\n"
yield "\n</thinking>\n\n"

turn = self.provider.stream_turn(
result,
Expand Down
44 changes: 31 additions & 13 deletions tests/test_stream_thinking.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,10 @@ def test_tag_chunks_are_separate(self):


class TestStreamThinkingAll:
"""Tests for content='all' mode — ContentThinking objects yielded, no tag strings."""
"""Tests for content='all' mode — ContentThinking objects AND tag boundary strings yielded."""

def test_thinking_then_text(self):
"""content='all' yields ContentThinking objects, not tag strings."""
"""content='all' yields tag boundaries AND ContentThinking objects."""
chunks = [
ContentThinking._as_chunk("step 1 "),
ContentThinking._as_chunk("step 2"),
Expand All @@ -145,15 +145,17 @@ def test_thinking_then_text(self):
result = list(chat.stream("test", content="all"))

thinking_chunks = [x for x in result if isinstance(x, ContentThinking)]
text_chunks = [x for x in result if isinstance(x, str)]
str_chunks = [x for x in result if isinstance(x, str)]

assert len(thinking_chunks) == 2
assert thinking_chunks[0].thinking == "step 1 "
assert thinking_chunks[1].thinking == "step 2"
assert text_chunks == ["Hello"]
assert "<thinking>\n" in str_chunks
assert "\n</thinking>\n\n" in str_chunks
assert "Hello" in str_chunks

def test_no_tag_strings_yielded(self):
"""content='all' mode should NOT yield tag boundary strings."""
def test_tag_boundaries_yielded(self):
"""content='all' mode SHOULD yield tag boundary strings."""
chunks = [
ContentThinking._as_chunk("thought"),
ContentText.model_construct(text="answer"),
Expand All @@ -162,9 +164,23 @@ def test_no_tag_strings_yielded(self):
result = list(chat.stream("test", content="all"))

str_chunks = [x for x in result if isinstance(x, str)]
for s in str_chunks:
assert "<thinking>" not in s
assert "</thinking>" not in s
assert "<thinking>\n" in str_chunks
assert "\n</thinking>\n\n" in str_chunks

def test_order_of_chunks(self):
"""content='all' mode: open tag, ContentThinking objects, close tag, then text."""
chunks = [
ContentThinking._as_chunk("thought"),
ContentText.model_construct(text="answer"),
]
chat = _make_chat(chunks)
result = list(chat.stream("test", content="all"))

assert result[0] == "<thinking>\n"
assert isinstance(result[1], ContentThinking)
assert result[1].thinking == "thought"
assert result[2] == "\n</thinking>\n\n"
assert result[3] == "answer"

def test_str_on_chunk_has_no_tags(self):
"""Calling str() on yielded ContentThinking chunks should not wrap in tags."""
Expand Down Expand Up @@ -207,7 +223,7 @@ async def test_thinking_only_async(self):
assert combined == "<thinking>\nreasoning\n</thinking>\n\n"

async def test_content_all_async(self):
"""Async content='all' yields ContentThinking objects, no tag strings."""
"""Async content='all' yields tag boundaries AND ContentThinking objects."""
chunks = [
ContentThinking._as_chunk("thought"),
ContentText.model_construct(text="answer"),
Expand All @@ -220,6 +236,8 @@ async def test_content_all_async(self):

assert len(thinking_chunks) == 1
assert thinking_chunks[0].thinking == "thought"
assert str_chunks == ["answer"]
for s in str_chunks:
assert "<thinking>" not in s
assert "<thinking>\n" in str_chunks
assert "\n</thinking>\n\n" in str_chunks
assert "answer" in str_chunks
assert result[0] == "<thinking>\n"
assert result[2] == "\n</thinking>\n\n"
Loading