Skip to content

Commit 309dc3b

Browse files
Merge pull request #11 from InterfazeAI/fix/stream-tag-filter
fix: strip <think>/<precontext> from live stream deltas (#4)
2 parents 6880d48 + 558e0b2 commit 309dc3b

3 files changed

Lines changed: 176 additions & 5 deletions

File tree

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,23 @@ res = interfaze.chat.completions.create(
7878

7979
## Streaming
8080

81+
For live rendering, iterate `text_deltas()` — it yields visible text only, stripping Interfaze's
82+
inline `<think>`/`<precontext>` side-channels:
83+
8184
```python
8285
stream = interfaze.chat.completions.stream(
8386
messages=[{"role": "user", "content": "Tell me a story."}],
8487
)
85-
for chunk in stream:
86-
print(chunk.choices[0].delta.content or "", end="")
88+
for text in stream.text_deltas():
89+
print(text, end="")
8790
final = stream.get_final_completion()
8891
print(final.reasoning, final.precontext)
8992
```
9093

91-
> Plain `create(stream=True)` also works and returns the raw chunk iterator; `.stream()` adds
92-
> accumulation and surfaces `reasoning`/`precontext`.
94+
> Iterating the stream directly (`for chunk in stream`) or the plain `create(stream=True)` path
95+
> yields **raw** chunks whose `delta.content` still contains the `<think>`/`<precontext>` tags — use
96+
> `text_deltas()` for anything user-facing. `.stream()` also accumulates and surfaces
97+
> `reasoning`/`precontext` on `get_final_completion()`.
9398
9499
## Inputs
95100

src/interfaze/_stream.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,67 @@ def strip_side_channels(content: str) -> Tuple[str, Optional[str], Optional[List
3030
return text.strip(), ("\n".join(thinks) if thinks else None), (pre or None)
3131

3232

33+
_SIDE_OPEN = ("<think>", "<precontext>")
34+
_SIDE_CLOSE = {"<think>": "</think>", "<precontext>": "</precontext>"}
35+
36+
37+
def _suffix_prefix_len(s: str, tag: str) -> int:
38+
for k in range(min(len(s), len(tag) - 1), 0, -1):
39+
if s[-k:] == tag[:k]:
40+
return k
41+
return 0
42+
43+
44+
class _SideChannelFilter:
45+
"""Strip inline ``<think>``/``<precontext>`` blocks from streamed content, chunk by chunk.
46+
47+
Buffers a trailing partial that may be a split tag; never withholds text that cannot be a tag.
48+
"""
49+
50+
def __init__(self) -> None:
51+
self._buf = ""
52+
self._close: Optional[str] = None
53+
54+
def feed(self, text: str) -> str:
55+
self._buf += text
56+
out: List[str] = []
57+
while self._buf:
58+
if self._close is None:
59+
lt = self._buf.find("<")
60+
if lt == -1:
61+
out.append(self._buf)
62+
self._buf = ""
63+
break
64+
if lt:
65+
out.append(self._buf[:lt])
66+
self._buf = self._buf[lt:]
67+
opened = next((t for t in _SIDE_OPEN if self._buf.startswith(t)), None)
68+
if opened:
69+
self._close = _SIDE_CLOSE[opened]
70+
self._buf = self._buf[len(opened) :]
71+
continue
72+
if any(t.startswith(self._buf) for t in _SIDE_OPEN):
73+
break
74+
out.append("<")
75+
self._buf = self._buf[1:]
76+
else:
77+
end = self._buf.find(self._close)
78+
if end == -1:
79+
keep = _suffix_prefix_len(self._buf, self._close)
80+
self._buf = self._buf[len(self._buf) - keep :] if keep else ""
81+
break
82+
self._buf = self._buf[end + len(self._close) :]
83+
self._close = None
84+
return "".join(out)
85+
86+
def flush(self) -> str:
87+
if self._close is not None:
88+
self._buf = ""
89+
return ""
90+
rest, self._buf = self._buf, ""
91+
return rest
92+
93+
3394
class _State:
3495
def __init__(self) -> None:
3596
self.content = ""
@@ -117,6 +178,29 @@ def __iter__(self) -> "Iterator[ChatCompletionChunk]":
117178
yield chunk
118179
self._done = True
119180

181+
def text_deltas(self) -> "Iterator[str]":
182+
"""Yield visible text only, stripping ``<think>``/``<precontext>`` across chunk boundaries.
183+
184+
Use this (not raw ``create(stream=True)`` deltas) for live rendering. ``reasoning`` and
185+
``precontext`` remain available on ``get_final_completion()``.
186+
"""
187+
if self._started:
188+
raise InterfazeError("This stream has already been consumed.")
189+
self._started = True
190+
filt = _SideChannelFilter()
191+
for chunk in self._client.chat.completions.create(stream=True, **self._kwargs):
192+
self._state.accumulate(chunk)
193+
if chunk.choices:
194+
delta = chunk.choices[0].delta
195+
if delta and isinstance(delta.content, str) and delta.content:
196+
visible = filt.feed(delta.content)
197+
if visible:
198+
yield visible
199+
tail = filt.flush()
200+
if tail:
201+
yield tail
202+
self._done = True
203+
120204
@property
121205
def text(self) -> str:
122206
return strip_side_channels(self._state.content)[0]
@@ -160,6 +244,30 @@ async def __aiter__(self) -> "AsyncIterator[ChatCompletionChunk]":
160244
yield chunk
161245
self._done = True
162246

247+
async def text_deltas(self) -> "AsyncIterator[str]":
248+
"""Yield visible text only, stripping ``<think>``/``<precontext>`` across chunk boundaries.
249+
250+
Use this (not raw ``create(stream=True)`` deltas) for live rendering. ``reasoning`` and
251+
``precontext`` remain available on ``get_final_completion()``.
252+
"""
253+
if self._started:
254+
raise InterfazeError("This stream has already been consumed.")
255+
self._started = True
256+
filt = _SideChannelFilter()
257+
stream = await self._client.chat.completions.create(stream=True, **self._kwargs)
258+
async for chunk in stream:
259+
self._state.accumulate(chunk)
260+
if chunk.choices:
261+
delta = chunk.choices[0].delta
262+
if delta and isinstance(delta.content, str) and delta.content:
263+
visible = filt.feed(delta.content)
264+
if visible:
265+
yield visible
266+
tail = filt.flush()
267+
if tail:
268+
yield tail
269+
self._done = True
270+
163271
async def get_final_completion(self) -> InterfazeChatCompletion:
164272
if not self._started:
165273
self._started = True

tests/test_stream.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import asyncio
44

55
import respx
6-
from conftest import STREAM_CHUNKS, STREAM_THINK, mock_sse
6+
from conftest import STREAM_CHUNKS, STREAM_THINK, _chunk, mock_sse
77

88
from interfaze import AsyncInterfaze, Interfaze
99

@@ -46,3 +46,61 @@ async def go():
4646
n, final = asyncio.run(go())
4747
assert n == len(STREAM_CHUNKS)
4848
assert final.precontext and final.precontext[0].name == "ocr"
49+
50+
51+
@respx.mock
52+
def test_text_deltas_strips_precontext():
53+
mock_sse(STREAM_CHUNKS)
54+
stream = Interfaze(api_key="t").chat.completions.stream(messages=[{"role": "user", "content": "x"}])
55+
text = "".join(stream.text_deltas())
56+
assert text == "Total is $12.34"
57+
assert "<precontext>" not in text
58+
59+
60+
@respx.mock
61+
def test_text_deltas_strips_think():
62+
mock_sse(STREAM_THINK)
63+
stream = Interfaze(api_key="t").chat.completions.stream(messages=[{"role": "user", "content": "x"}])
64+
text = "".join(stream.text_deltas())
65+
assert text == "The sky is blue."
66+
assert "<think>" not in text
67+
68+
69+
@respx.mock
70+
def test_text_deltas_handles_tag_split_across_chunks():
71+
chunks = [
72+
_chunk({"content": "Hello <pre"}),
73+
_chunk({"content": 'context>[{"name":"ocr","result":1}]</precon'}),
74+
_chunk({"content": "text> world"}),
75+
_chunk({}, finish_reason="stop"),
76+
]
77+
mock_sse(chunks)
78+
stream = Interfaze(api_key="t").chat.completions.stream(messages=[{"role": "user", "content": "x"}])
79+
text = "".join(stream.text_deltas())
80+
assert "precontext" not in text and "ocr" not in text
81+
assert text == "Hello world"
82+
83+
84+
@respx.mock
85+
def test_text_deltas_preserves_literal_lt():
86+
chunks = [
87+
_chunk({"content": "a < b and c "}),
88+
_chunk({"content": "< d"}),
89+
_chunk({}, finish_reason="stop"),
90+
]
91+
mock_sse(chunks)
92+
stream = Interfaze(api_key="t").chat.completions.stream(messages=[{"role": "user", "content": "x"}])
93+
assert "".join(stream.text_deltas()) == "a < b and c < d"
94+
95+
96+
@respx.mock
97+
def test_async_text_deltas():
98+
mock_sse(STREAM_CHUNKS)
99+
100+
async def go():
101+
stream = AsyncInterfaze(api_key="t").chat.completions.stream(
102+
messages=[{"role": "user", "content": "x"}]
103+
)
104+
return "".join([t async for t in stream.text_deltas()])
105+
106+
assert asyncio.run(go()) == "Total is $12.34"

0 commit comments

Comments
 (0)