55from typing import Any , AsyncIterator , Dict , Iterator , List , Optional , Tuple
66
77from openai import AsyncOpenAI , OpenAI
8+ from openai .lib .streaming .chat import ChatCompletionStreamEvent , ChatCompletionStreamState
89from openai .types .chat import ChatCompletionChunk
910
1011from ._errors import InterfazeError
@@ -53,10 +54,7 @@ def _suffix_prefix_len(s: str, tag: str) -> int:
5354
5455
5556class _SideChannelFilter :
56- """Strip inline ``<think>``/``<precontext>`` blocks from streamed content, chunk by chunk.
57-
58- Buffers a trailing partial that may be a split tag; never withholds text that cannot be a tag.
59- """
57+ """Incrementally strips ``<think>``/``<precontext>`` blocks, buffering tags split across chunks."""
6058
6159 def __init__ (self ) -> None :
6260 self ._buf = ""
@@ -102,6 +100,26 @@ def flush(self) -> str:
102100 return rest
103101
104102
103+ def _clean_for_events (
104+ chunk : ChatCompletionChunk , filt : _SideChannelFilter , role_injected : bool
105+ ) -> "Tuple[ChatCompletionChunk, bool]" :
106+ """Clean a chunk for openai's event state: strip side channels from content, inject a role."""
107+ cleaned = chunk .model_copy (deep = True )
108+ if not cleaned .choices :
109+ return cleaned , role_injected
110+ delta = cleaned .choices [0 ].delta
111+ if not role_injected :
112+ role_injected = True
113+ if not delta .role :
114+ delta .role = "assistant"
115+ piece = filt .feed (delta .content ) if isinstance (delta .content , str ) else ""
116+ if cleaned .choices [0 ].finish_reason :
117+ piece += filt .flush ()
118+ if isinstance (delta .content , str ) or piece :
119+ delta .content = piece
120+ return cleaned , role_injected
121+
122+
105123class _State :
106124 def __init__ (self ) -> None :
107125 self .content = ""
@@ -111,6 +129,8 @@ def __init__(self) -> None:
111129 self .model = ""
112130 self .created = 0
113131 self .tool_calls : Dict [int , Dict [str , str ]] = {}
132+ self .usage : Any = None
133+ self .system_fingerprint : Optional [str ] = None
114134
115135 def accumulate (self , chunk : ChatCompletionChunk ) -> None :
116136 if not self .id and chunk .id :
@@ -119,6 +139,10 @@ def accumulate(self, chunk: ChatCompletionChunk) -> None:
119139 self .model = chunk .model
120140 if not self .created and chunk .created :
121141 self .created = chunk .created
142+ if chunk .usage :
143+ self .usage = chunk .usage
144+ if chunk .system_fingerprint :
145+ self .system_fingerprint = chunk .system_fingerprint
122146 if not chunk .choices :
123147 return
124148 choice = chunk .choices [0 ]
@@ -159,6 +183,10 @@ def build(self, strip_fence: bool = False) -> InterfazeChatCompletion:
159183 ],
160184 "vcache" : False ,
161185 }
186+ if self .usage is not None :
187+ data ["usage" ] = self .usage .model_dump ()
188+ if self .system_fingerprint :
189+ data ["system_fingerprint" ] = self .system_fingerprint
162190 if reasoning :
163191 data ["reasoning" ] = reasoning
164192 if precontext :
@@ -167,13 +195,16 @@ def build(self, strip_fence: bool = False) -> InterfazeChatCompletion:
167195
168196
169197class InterfazeStream :
170- """Sync streaming helper — iterate chunks , then ``get_final_completion()``."""
198+ """Sync streaming helper — iterate OpenAI-style events , then ``get_final_completion()``."""
171199
172200 def __init__ (self , client : OpenAI , kwargs : Dict [str , Any ], strip_fence : bool = False ) -> None :
173201 self ._client = client
174202 self ._kwargs = kwargs
175203 self ._strip_fence = strip_fence
176204 self ._state = _State ()
205+ self ._openai_state : ChatCompletionStreamState [None ] = ChatCompletionStreamState ()
206+ self ._filter = _SideChannelFilter ()
207+ self ._role_injected = False
177208 self ._started = False
178209 self ._done = False
179210
@@ -183,36 +214,31 @@ def __enter__(self) -> "InterfazeStream":
183214 def __exit__ (self , * exc : Any ) -> None :
184215 return None
185216
186- def __iter__ (self ) -> "Iterator[ChatCompletionChunk ]" :
217+ def __iter__ (self ) -> "Iterator[ChatCompletionStreamEvent[None] ]" :
187218 if self ._started :
188219 raise InterfazeError ("This stream has already been consumed." )
189220 self ._started = True
190221 for chunk in self ._client .chat .completions .create (stream = True , ** self ._kwargs ):
191222 self ._state .accumulate (chunk )
192- yield chunk
223+ cleaned , self ._role_injected = _clean_for_events (chunk , self ._filter , self ._role_injected )
224+ yield from self ._openai_state .handle_chunk (cleaned )
193225 self ._done = True
194226
195227 def text_deltas (self ) -> "Iterator[str]" :
196- """Yield visible text only, stripping ``<think>``/``<precontext>`` across chunk boundaries.
197-
198- Use this (not raw ``create(stream=True)`` deltas) for live rendering. ``reasoning`` and
199- ``precontext`` remain available on ``get_final_completion()``.
200- """
228+ """Iterate only the visible text (no side channels, no events) — for plain-token consumers."""
201229 if self ._started :
202230 raise InterfazeError ("This stream has already been consumed." )
203231 self ._started = True
204- filt = _SideChannelFilter ()
205232 for chunk in self ._client .chat .completions .create (stream = True , ** self ._kwargs ):
206233 self ._state .accumulate (chunk )
207- if chunk .choices :
208- delta = chunk .choices [0 ].delta
209- if delta and isinstance (delta .content , str ) and delta .content :
210- visible = filt .feed (delta .content )
211- if visible :
212- yield visible
213- tail = filt .flush ()
214- if tail :
215- yield tail
234+ if not chunk .choices :
235+ continue
236+ delta = chunk .choices [0 ].delta
237+ piece = self ._filter .feed (delta .content ) if isinstance (delta .content , str ) else ""
238+ if chunk .choices [0 ].finish_reason :
239+ piece += self ._filter .flush ()
240+ if piece :
241+ yield piece
216242 self ._done = True
217243
218244 @property
@@ -234,13 +260,16 @@ def get_final_completion(self) -> InterfazeChatCompletion:
234260
235261
236262class AsyncInterfazeStream :
237- """Async streaming helper — ``async for`` chunks , then ``await get_final_completion()``."""
263+ """Async streaming helper — ``async for`` OpenAI-style events , then ``await get_final_completion()``."""
238264
239265 def __init__ (self , client : AsyncOpenAI , kwargs : Dict [str , Any ], strip_fence : bool = False ) -> None :
240266 self ._client = client
241267 self ._kwargs = kwargs
242268 self ._strip_fence = strip_fence
243269 self ._state = _State ()
270+ self ._openai_state : ChatCompletionStreamState [None ] = ChatCompletionStreamState ()
271+ self ._filter = _SideChannelFilter ()
272+ self ._role_injected = False
244273 self ._started = False
245274 self ._done = False
246275
@@ -250,38 +279,34 @@ async def __aenter__(self) -> "AsyncInterfazeStream":
250279 async def __aexit__ (self , * exc : Any ) -> None :
251280 return None
252281
253- async def __aiter__ (self ) -> "AsyncIterator[ChatCompletionChunk ]" :
282+ async def __aiter__ (self ) -> "AsyncIterator[ChatCompletionStreamEvent[None] ]" :
254283 if self ._started :
255284 raise InterfazeError ("This stream has already been consumed." )
256285 self ._started = True
257286 stream = await self ._client .chat .completions .create (stream = True , ** self ._kwargs )
258287 async for chunk in stream :
259288 self ._state .accumulate (chunk )
260- yield chunk
289+ cleaned , self ._role_injected = _clean_for_events (chunk , self ._filter , self ._role_injected )
290+ for event in self ._openai_state .handle_chunk (cleaned ):
291+ yield event
261292 self ._done = True
262293
263294 async def text_deltas (self ) -> "AsyncIterator[str]" :
264- """Yield visible text only, stripping ``<think>``/``<precontext>`` across chunk boundaries.
265-
266- Use this (not raw ``create(stream=True)`` deltas) for live rendering. ``reasoning`` and
267- ``precontext`` remain available on ``get_final_completion()``.
268- """
295+ """Iterate only the visible text (no side channels, no events) — for plain-token consumers."""
269296 if self ._started :
270297 raise InterfazeError ("This stream has already been consumed." )
271298 self ._started = True
272- filt = _SideChannelFilter ()
273299 stream = await self ._client .chat .completions .create (stream = True , ** self ._kwargs )
274300 async for chunk in stream :
275301 self ._state .accumulate (chunk )
276- if chunk .choices :
277- delta = chunk .choices [0 ].delta
278- if delta and isinstance (delta .content , str ) and delta .content :
279- visible = filt .feed (delta .content )
280- if visible :
281- yield visible
282- tail = filt .flush ()
283- if tail :
284- yield tail
302+ if not chunk .choices :
303+ continue
304+ delta = chunk .choices [0 ].delta
305+ piece = self ._filter .feed (delta .content ) if isinstance (delta .content , str ) else ""
306+ if chunk .choices [0 ].finish_reason :
307+ piece += self ._filter .flush ()
308+ if piece :
309+ yield piece
285310 self ._done = True
286311
287312 async def get_final_completion (self ) -> InterfazeChatCompletion :
0 commit comments