From cad201194199cdaa08b7ed7a98d2eafd8423d470 Mon Sep 17 00:00:00 2001 From: Abhinavexist Date: Tue, 21 Jul 2026 04:12:46 +0530 Subject: [PATCH] fix: address OpenAI compliance audit findings - Streamed usage was dropped: _State never read chunk.usage (the usage chunk has empty choices, so accumulate returned before reading it). Capture chunk.usage + system_fingerprint and surface them on get_final_completion(). (audit #2 - the real bug) - Carry the server's _request_id through to_interfaze so it survives the model_dump/model_validate round-trip. (audit #3) - Fix pyproject Repository URL (interfaze/interfaze-py -> InterfazeAI/interfaze-python). (audit appendix) - README 'Compatibility notes': .stream() yields chunks not events; content post-processing (json fence / / tags); inputs.* URL non-portability; the interfaze.openai escape hatch; tasks.* return raw results. (audit #1/#4/#5/#6 + task-return note) Regression tests in tests/test_compliance.py (usage sync+async, request_id). --- README.md | 19 +++++++++++++ pyproject.toml | 2 +- src/interfaze/_chat.py | 6 +++- src/interfaze/_stream.py | 10 +++++++ tests/test_compliance.py | 59 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/test_compliance.py diff --git a/README.md b/README.md index 16c09f4..881bb54 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,25 @@ URLs and base64 work; raw `bytes` do **not** (must be base64-encoded — this SD - `n`, `seed`, `stop`, penalties, `logprobs`, `tool_choice`, `top_k` are ignored by Interfaze. - The underlying OpenAI client is available at `interfaze.openai`. +## Compatibility notes + +Drop-in for the OpenAI **chat completions** flow (`create`, response types, errors, +`create(stream=True)`, `models`), with a few behaviors worth knowing when migrating: + +- **`.stream()` yields chunks, not events.** OpenAI's `.stream()` helper yields event objects + (`event.type == "content.delta"`); ours yields raw `ChatCompletionChunk`s (like + `create(stream=True)`) plus `get_final_completion()`. Iterate `chunk.choices[0].delta`, not + `event.type`. +- **Returned text is lightly post-processed.** `json_object` content is unwrapped from its + ```` ```json ```` fence, and streamed ``/`` side-channels are pulled into + `reasoning`/`precontext`, so `message.content` may not be byte-identical to the raw wire response. +- **`inputs.*` accept https URLs** (Interfaze fetches them server-side). Those parts are valid for + Interfaze but **not** portable to OpenAI/Azure, which require base64 in `file`/`input_audio` parts. +- **Escape hatch:** anything not on the wrapper — `chat.completions.parse()`, `.with_raw_response`, + `.with_streaming_response` — is on the underlying client at `interfaze.openai`. +- **`tasks.*` return the extracted result** (a `dict`/`list`/`str`), not a `ChatCompletion` — e.g. + `tasks.ocr(...)` returns the OCR dict directly. + ## License MIT diff --git a/pyproject.toml b/pyproject.toml index 618d0df..de430ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = ["openai>=2,<3"] [project.urls] Homepage = "https://interfaze.ai" -Repository = "https://github.com/interfaze/interfaze-py" +Repository = "https://github.com/InterfazeAI/interfaze-python" [project.optional-dependencies] dev = ["pytest>=8", "respx>=0.21", "mypy>=1.11", "ruff>=0.6", "pydantic>=2"] diff --git a/src/interfaze/_chat.py b/src/interfaze/_chat.py index de9ce15..e080c08 100644 --- a/src/interfaze/_chat.py +++ b/src/interfaze/_chat.py @@ -62,7 +62,11 @@ def to_interfaze(raw: ChatCompletion, strip_fence: bool) -> InterfazeChatComplet msg["content"] = strip_json_fence(msg["content"]) except (KeyError, IndexError, TypeError): pass - return InterfazeChatCompletion.model_validate(data) + result = InterfazeChatCompletion.model_validate(data) + request_id = getattr(raw, "_request_id", None) + if request_id is not None: + result._request_id = request_id + return result class _CompletionsBase: diff --git a/src/interfaze/_stream.py b/src/interfaze/_stream.py index b000184..923d9f2 100644 --- a/src/interfaze/_stream.py +++ b/src/interfaze/_stream.py @@ -39,6 +39,8 @@ def __init__(self) -> None: self.model = "" self.created = 0 self.tool_calls: Dict[int, Dict[str, str]] = {} + self.usage: Any = None + self.system_fingerprint: Optional[str] = None def accumulate(self, chunk: ChatCompletionChunk) -> None: if not self.id and chunk.id: @@ -47,6 +49,10 @@ def accumulate(self, chunk: ChatCompletionChunk) -> None: self.model = chunk.model if not self.created and chunk.created: self.created = chunk.created + if chunk.usage: + self.usage = chunk.usage + if chunk.system_fingerprint: + self.system_fingerprint = chunk.system_fingerprint if not chunk.choices: return choice = chunk.choices[0] @@ -85,6 +91,10 @@ def build(self) -> InterfazeChatCompletion: ], "vcache": False, } + if self.usage is not None: + data["usage"] = self.usage.model_dump() + if self.system_fingerprint: + data["system_fingerprint"] = self.system_fingerprint if reasoning: data["reasoning"] = reasoning if precontext: diff --git a/tests/test_compliance.py b/tests/test_compliance.py new file mode 100644 index 0000000..31baafa --- /dev/null +++ b/tests/test_compliance.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import asyncio + +import httpx +import respx +from conftest import BASIC, CHAT_URL, _chunk, mock_sse + +from interfaze import AsyncInterfaze, Interfaze + +USAGE_CHUNK = { + "id": "req-test", + "object": "chat.completion.chunk", + "created": 1_700_000_000, + "model": "interfaze-beta", + "choices": [], + "usage": {"prompt_tokens": 216, "completion_tokens": 10, "total_tokens": 226}, + "system_fingerprint": "fp_test", +} +STREAM_WITH_USAGE = [_chunk({"content": "Hi"}), _chunk({}, finish_reason="stop"), USAGE_CHUNK] + + +@respx.mock +def test_stream_captures_usage_and_fingerprint(): + mock_sse(STREAM_WITH_USAGE) + stream = Interfaze(api_key="t").chat.completions.stream( + messages=[{"role": "user", "content": "x"}], stream_options={"include_usage": True} + ) + final = stream.get_final_completion() + assert final.usage is not None + assert final.usage.prompt_tokens == 216 + assert final.usage.completion_tokens == 10 + assert final.usage.total_tokens == 226 + assert final.system_fingerprint == "fp_test" + + +@respx.mock +def test_async_stream_captures_usage(): + mock_sse(STREAM_WITH_USAGE) + + async def go(): + stream = AsyncInterfaze(api_key="t").chat.completions.stream( + messages=[{"role": "user", "content": "x"}], stream_options={"include_usage": True} + ) + async for _ in stream: + pass + return await stream.get_final_completion() + + final = asyncio.run(go()) + assert final.usage is not None and final.usage.total_tokens == 226 + + +@respx.mock +def test_request_id_carried_through(): + respx.post(CHAT_URL).mock( + return_value=httpx.Response(200, json=BASIC, headers={"x-request-id": "req-abc123"}) + ) + r = Interfaze(api_key="t").chat.completions.create(messages=[{"role": "user", "content": "x"}]) + assert r._request_id == "req-abc123"