@@ -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+
3394class _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
0 commit comments