Skip to content

Commit 2b127bd

Browse files
committed
fix: OpenAI compliance audit — .stream() events, streamed usage, request-id, escape hatches
- #1 .stream() now yields OpenAI ChatCompletionStreamEvents (content.delta/done, tool-call events) via ChatCompletionStreamState; <think>/<precontext> stripped from content events; text_deltas() for plain tokens; create(stream=True) unchanged. - #2 streamed usage + system_fingerprint captured on get_final_completion(). - #3 _request_id carried onto the returned completion. - #6 parse()/with_raw_response/with_streaming_response exposed on the wrapper (no more AttributeError; parse delegates to the OpenAI client, model default + guard). Rebased onto main (post #7#11).
1 parent 05ca033 commit 2b127bd

6 files changed

Lines changed: 399 additions & 98 deletions

File tree

README.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,23 +78,22 @@ 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:
81+
`.stream()` yields OpenAI-style events (`content.delta`, `content.done`, tool-call events, …) with
82+
Interfaze's inline `<think>`/`<precontext>` side-channels stripped from the content events:
8383

8484
```python
8585
stream = interfaze.chat.completions.stream(
8686
messages=[{"role": "user", "content": "Tell me a story."}],
8787
)
88-
for text in stream.text_deltas():
89-
print(text, end="")
88+
for event in stream:
89+
if event.type == "content.delta":
90+
print(event.delta, end="")
9091
final = stream.get_final_completion()
9192
print(final.reasoning, final.precontext)
9293
```
9394

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()`.
95+
> Just want clean tokens? `stream.text_deltas()` yields visible text only. Plain
96+
> `create(stream=True)` returns the raw chunk iterator (side-channel tags **not** stripped).
9897
9998
## Inputs
10099

@@ -152,6 +151,26 @@ precontext with `ChatInterfaze(precontext=[...])`.
152151
intermediary mid-job.
153152
- The underlying OpenAI client is available at `interfaze.openai`.
154153

154+
## Compatibility notes
155+
156+
Drop-in for the OpenAI **chat completions** flow (`create`, response types, errors,
157+
`create(stream=True)`, `models`), with a few behaviors worth knowing when migrating:
158+
159+
- **`.stream()` yields OpenAI-style events**, drop-in with OpenAI's streaming helper — iterate
160+
`event.type` (`"content.delta"` with `.delta`, `"content.done"`,
161+
`"tool_calls.function.arguments.delta"`/`.done`, …), same as `client.chat.completions.stream()`
162+
on the OpenAI SDK. `create(stream=True)` still gives the raw `ChatCompletionChunk` iterator, and
163+
`stream.text_deltas()` gives just the clean visible text if you don't need events.
164+
- **Returned text is lightly post-processed.** `json_object` content is unwrapped from its
165+
```` ```json ```` fence, and streamed `<think>`/`<precontext>` side-channels are pulled into
166+
`reasoning`/`precontext`, so `message.content` may not be byte-identical to the raw wire response.
167+
- **`inputs.*` accept https URLs** (Interfaze fetches them server-side). Those parts are valid for
168+
Interfaze but **not** portable to OpenAI/Azure, which require base64 in `file`/`input_audio` parts.
169+
- **Escape hatch:** anything not on the wrapper — `chat.completions.parse()`, `.with_raw_response`,
170+
`.with_streaming_response` — is on the underlying client at `interfaze.openai`.
171+
- **`tasks.*` return the extracted result** (a `dict`/`list`/`str`), not a `ChatCompletion` — e.g.
172+
`tasks.ocr(...)` returns the OCR dict directly.
173+
155174
## License
156175

157176
MIT

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ dependencies = ["openai>=2,<3"]
2020

2121
[project.urls]
2222
Homepage = "https://interfaze.ai"
23-
Repository = "https://github.com/interfaze/interfaze-py"
23+
Repository = "https://github.com/InterfazeAI/interfaze-python"
2424

2525
[project.optional-dependencies]
2626
langchain = ["langchain-openai>=1.3.5; python_version>='3.10'"]

src/interfaze/_chat.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any, Dict, Iterable, List, Literal, Optional, Union, cast, overload
44

55
from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
6-
from openai.types.chat import ChatCompletion, ChatCompletionChunk
6+
from openai.types.chat import ChatCompletion, ChatCompletionChunk, ParsedChatCompletion
77

88
from ._constants import INTERFAZE_MODEL
99
from ._errors import InterfazeError
@@ -63,7 +63,11 @@ def to_interfaze(raw: ChatCompletion, strip_fence: bool) -> InterfazeChatComplet
6363
msg["content"] = strip_json_fence(msg["content"])
6464
except (KeyError, IndexError, TypeError):
6565
pass
66-
return InterfazeChatCompletion.model_validate(data)
66+
result = InterfazeChatCompletion.model_validate(data)
67+
request_id = getattr(raw, "_request_id", None)
68+
if request_id is not None:
69+
result._request_id = request_id
70+
return result
6771

6872

6973
class _CompletionsBase:
@@ -146,6 +150,35 @@ def stream(
146150
kw, strip = self._kwargs(messages, model, task, guard, response_format, kwargs)
147151
return InterfazeStream(self._client, kw, strip)
148152

153+
def parse(
154+
self,
155+
*,
156+
messages: Iterable[Any],
157+
response_format: Any,
158+
model: str = INTERFAZE_MODEL,
159+
guard: Optional[List[GuardCode]] = None,
160+
**kwargs: Any,
161+
) -> "ParsedChatCompletion[Any]":
162+
"""Structured output via a Pydantic model (delegates to the OpenAI client's ``parse``).
163+
164+
Interfaze extras (``vcache``/``precontext``) are present but untyped here; use ``create`` for
165+
the typed extended completion.
166+
"""
167+
msgs = prepare(messages, model, None, guard, None)[0]
168+
return self._client.chat.completions.parse(
169+
model=model, messages=msgs, response_format=response_format, **kwargs
170+
)
171+
172+
@property
173+
def with_raw_response(self) -> Any:
174+
"""Raw-HTTP escape hatch (delegates to the OpenAI client; bypasses task/guard preprocessing)."""
175+
return self._client.chat.completions.with_raw_response
176+
177+
@property
178+
def with_streaming_response(self) -> Any:
179+
"""Streaming raw-HTTP escape hatch (delegates to the OpenAI client)."""
180+
return self._client.chat.completions.with_streaming_response
181+
149182

150183
class AsyncCompletions(_CompletionsBase):
151184
"""`interfaze.chat.completions` (async)."""
@@ -211,6 +244,35 @@ def stream(
211244
kw, strip = self._kwargs(messages, model, task, guard, response_format, kwargs)
212245
return AsyncInterfazeStream(self._client, kw, strip)
213246

247+
async def parse(
248+
self,
249+
*,
250+
messages: Iterable[Any],
251+
response_format: Any,
252+
model: str = INTERFAZE_MODEL,
253+
guard: Optional[List[GuardCode]] = None,
254+
**kwargs: Any,
255+
) -> "ParsedChatCompletion[Any]":
256+
"""Structured output via a Pydantic model (delegates to the OpenAI client's ``parse``).
257+
258+
Interfaze extras (``vcache``/``precontext``) are present but untyped here; use ``create`` for
259+
the typed extended completion.
260+
"""
261+
msgs = prepare(messages, model, None, guard, None)[0]
262+
return await self._client.chat.completions.parse(
263+
model=model, messages=msgs, response_format=response_format, **kwargs
264+
)
265+
266+
@property
267+
def with_raw_response(self) -> Any:
268+
"""Raw-HTTP escape hatch (delegates to the OpenAI client; bypasses task/guard preprocessing)."""
269+
return self._client.chat.completions.with_raw_response
270+
271+
@property
272+
def with_streaming_response(self) -> Any:
273+
"""Streaming raw-HTTP escape hatch (delegates to the OpenAI client)."""
274+
return self._client.chat.completions.with_streaming_response
275+
214276

215277
class Chat:
216278
def __init__(self, client: OpenAI) -> None:

src/interfaze/_stream.py

Lines changed: 65 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple
66

77
from openai import AsyncOpenAI, OpenAI
8+
from openai.lib.streaming.chat import ChatCompletionStreamEvent, ChatCompletionStreamState
89
from openai.types.chat import ChatCompletionChunk
910

1011
from ._errors import InterfazeError
@@ -53,10 +54,7 @@ def _suffix_prefix_len(s: str, tag: str) -> int:
5354

5455

5556
class _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+
105123
class _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

169197
class 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

236262
class 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

Comments
 (0)