From baedd72602a4979e6b5bf6bcd14deffb5f3d8987 Mon Sep 17 00:00:00 2001 From: Abhinavexist Date: Sat, 18 Jul 2026 02:15:14 +0530 Subject: [PATCH 1/3] fix: tolerate raw tool-call entries in precontext (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `to_interfaze` re-validated the completion through a strict `Precontext` (name required), but the server appends raw model tool-calls `{toolCallId, toolName, input}` to `precontext` on any tool / run_code turn — no `name`/`result` — so `create()` raised `ValidationError` on those turns. Make `Precontext` lenient (optional `name`, `extra="allow"`), mirroring openai-python's "validate loosely, preserve everything": re-validation now never raises, well-formed entries stay typed, and raw tool-call entries are preserved as extras. Typed `choices`/`message` are unaffected. Adds a mixed-precontext fixture + regression test. Fixes #1 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/interfaze/_types.py | 35 +++++++++++++++++++++++++++++------ tests/conftest.py | 9 +++++++++ tests/test_chat.py | 15 +++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/interfaze/_types.py b/src/interfaze/_types.py index 9292424..f403505 100644 --- a/src/interfaze/_types.py +++ b/src/interfaze/_types.py @@ -3,7 +3,7 @@ from typing import Any, List, Literal, Optional from openai.types.chat import ChatCompletion -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict TaskName = Literal[ "ocr", @@ -16,9 +16,23 @@ ] GuardCode = Literal[ - "S1", "S2", "S3", "S4", "S5", "S6", "S7", - "S8", "S9", "S10", "S11", "S12", "S13", "S14", - "S1_IMAGE", "S12_IMAGE", "S15_IMAGE", + "S1", + "S2", + "S3", + "S4", + "S5", + "S6", + "S7", + "S8", + "S9", + "S10", + "S11", + "S12", + "S13", + "S14", + "S1_IMAGE", + "S12_IMAGE", + "S15_IMAGE", "ALL", ] @@ -27,9 +41,18 @@ class Precontext(BaseModel): - """One internal task's raw output, surfaced in ``response.precontext``.""" + """One internal task's raw output, surfaced in ``response.precontext``. - name: str + Lenient by design: when the final model calls a tool (user-defined tools or internal + action tools like ``run_code``), the server appends the raw tool-call objects + ``{toolCallId, toolName, input}`` here — with no ``name``/``result``. So both fields are + optional and unknown keys are preserved (openai-python's "validate loosely, preserve + everything" rule), and re-validating the completion never raises on a tool-call turn. + """ + + model_config = ConfigDict(extra="allow") + + name: Optional[str] = None result: Any = None diff --git a/tests/conftest.py b/tests/conftest.py index cb15d90..cc4071f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,15 @@ def completion( PRECONTEXT = completion( "Total: $12.34", precontext=[{"name": "ocr", "result": {"extracted_text": "Walmart ... TOTAL 12.34"}}] ) +# Mixed precontext: a well-formed task entry + a RAW model tool-call entry (as the server +# appends on any tool / run_code turn) — {toolCallId, toolName, input}, no name/result. +MIXED_PRECONTEXT = completion( + "Ran the code.", + precontext=[ + {"name": "ocr", "result": {"extracted_text": "x"}}, + {"toolCallId": "call_1", "toolName": "run_code", "input": {"code": "print(1)"}}, + ], +) REASONING = completion( "The sky is blue because...", reasoning="Rayleigh scattering means shorter wavelengths..." ) diff --git a/tests/test_chat.py b/tests/test_chat.py index 892caa7..894a2ff 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -8,6 +8,7 @@ from conftest import ( BASIC, JSON_OBJECT, + MIXED_PRECONTEXT, PRECONTEXT, REASONING, TASK_OCR, @@ -106,6 +107,20 @@ def test_precontext_and_vcache_typed(): assert isinstance(r.vcache, bool) +@respx.mock +def test_precontext_tolerates_raw_toolcall_entries(): + # Regression (issue #1): a tool/run_code turn appends raw {toolCallId,toolName,input} + # entries to precontext (no name/result). create() must not raise ValidationError. + mock_json(MIXED_PRECONTEXT) + r = Interfaze(api_key="t").chat.completions.create(messages=[{"role": "user", "content": "run code"}]) + assert isinstance(r, InterfazeChatCompletion) + assert len(r.precontext) == 2 + assert r.precontext[0].name == "ocr" and r.precontext[0].result == {"extracted_text": "x"} + # raw tool-call entry: no name/result, but preserved via extra="allow" + assert r.precontext[1].name is None + assert (r.precontext[1].model_extra or {}).get("toolName") == "run_code" + + @respx.mock def test_reasoning_typed(): mock_json(REASONING) From cbe7dcc6e7914de4df7278dc949fb4eacacfef33 Mon Sep 17 00:00:00 2001 From: Abhinavexist Date: Sat, 18 Jul 2026 02:26:13 +0530 Subject: [PATCH 2/3] Trim verbose comments to reduce prompt-injection surface Co-Authored-By: Claude Opus 4.8 (1M context) --- src/interfaze/_types.py | 21 ++------------------- tests/conftest.py | 3 +-- tests/test_chat.py | 4 +--- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/interfaze/_types.py b/src/interfaze/_types.py index f403505..052df7a 100644 --- a/src/interfaze/_types.py +++ b/src/interfaze/_types.py @@ -36,38 +36,21 @@ "ALL", ] -# Interfaze accepts these reasoning levels (wider than the OpenAI enum). ReasoningEffort = Literal["minimal", "low", "medium", "high", "on", "off", "auto"] class Precontext(BaseModel): - """One internal task's raw output, surfaced in ``response.precontext``. - - Lenient by design: when the final model calls a tool (user-defined tools or internal - action tools like ``run_code``), the server appends the raw tool-call objects - ``{toolCallId, toolName, input}`` here — with no ``name``/``result``. So both fields are - optional and unknown keys are preserved (openai-python's "validate loosely, preserve - everything" rule), and re-validating the completion never raises on a tool-call turn. - """ + """One internal task's output; lenient since raw tool-call entries omit name/result.""" model_config = ConfigDict(extra="allow") - name: Optional[str] = None result: Any = None class InterfazeChatCompletion(ChatCompletion): - """A chat completion extended with the fields Interfaze adds. - - openai-python already preserves these as pydantic extras (``extra='allow'``); this - subclass merely gives them declared, typed attributes for IDE/type-checker support. - """ + """ChatCompletion plus Interfaze extras: precontext, reasoning, vcache, debug.""" precontext: Optional[List[Precontext]] = None - """Present when internal tools ran (OCR / web search / scrape / STT / forecast / …).""" reasoning: Optional[str] = None - """Reasoning text — present with ``reasoning_effort='high'`` and no schema.""" vcache: bool = False - """Whether the semantic cache was hit.""" debug: Optional[Any] = None - """Admin-only debug payload (requires ``admin_key``).""" diff --git a/tests/conftest.py b/tests/conftest.py index cc4071f..2c96333 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,8 +42,7 @@ def completion( PRECONTEXT = completion( "Total: $12.34", precontext=[{"name": "ocr", "result": {"extracted_text": "Walmart ... TOTAL 12.34"}}] ) -# Mixed precontext: a well-formed task entry + a RAW model tool-call entry (as the server -# appends on any tool / run_code turn) — {toolCallId, toolName, input}, no name/result. +# A task entry plus a raw tool-call entry (server-appended on tool/run_code turns). MIXED_PRECONTEXT = completion( "Ran the code.", precontext=[ diff --git a/tests/test_chat.py b/tests/test_chat.py index 894a2ff..da5b32f 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -109,14 +109,12 @@ def test_precontext_and_vcache_typed(): @respx.mock def test_precontext_tolerates_raw_toolcall_entries(): - # Regression (issue #1): a tool/run_code turn appends raw {toolCallId,toolName,input} - # entries to precontext (no name/result). create() must not raise ValidationError. + # Regression for issue #1: raw tool-call entries in precontext must not raise. mock_json(MIXED_PRECONTEXT) r = Interfaze(api_key="t").chat.completions.create(messages=[{"role": "user", "content": "run code"}]) assert isinstance(r, InterfazeChatCompletion) assert len(r.precontext) == 2 assert r.precontext[0].name == "ocr" and r.precontext[0].result == {"extracted_text": "x"} - # raw tool-call entry: no name/result, but preserved via extra="allow" assert r.precontext[1].name is None assert (r.precontext[1].model_extra or {}).get("toolName") == "run_code" From 9bc9487fde01449c1da680295bd773712e29bbc5 Mon Sep 17 00:00:00 2001 From: Abhinavexist Date: Sat, 18 Jul 2026 03:19:14 +0530 Subject: [PATCH 3/3] chore(ruff): fix formating for readability, typechecker assertion fix --- src/interfaze/__init__.py | 3 ++- src/interfaze/_client.py | 8 ++++++-- src/interfaze/_constants.py | 20 +++++++++++++++++--- src/interfaze/_inputs.py | 37 +++++++++++++++++++++++++++++-------- src/interfaze/_tasks.py | 8 ++++++-- tests/conftest.py | 20 ++++++++++++++------ tests/test_chat.py | 4 +++- 7 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/interfaze/__init__.py b/src/interfaze/__init__.py index 65e7fd5..4dc2f61 100644 --- a/src/interfaze/__init__.py +++ b/src/interfaze/__init__.py @@ -1,4 +1,5 @@ -"""Official Interfaze SDK — a typed wrapper over the OpenAI SDK.""" +"""Official Interfaze SDK for Python.""" + from __future__ import annotations from openai import ( diff --git a/src/interfaze/_client.py b/src/interfaze/_client.py index 2dcf4d6..00eab25 100644 --- a/src/interfaze/_client.py +++ b/src/interfaze/_client.py @@ -67,7 +67,9 @@ def __init__( self.openai = OpenAI( api_key=_resolve_key(api_key), base_url=base_url or INTERFAZE_BASE_URL, - default_headers=_build_headers(default_headers, show_additional_info, bypass_moe, bypass_cache, admin_key), + default_headers=_build_headers( + default_headers, show_additional_info, bypass_moe, bypass_cache, admin_key + ), **kwargs, ) self.chat = Chat(self.openai) @@ -93,7 +95,9 @@ def __init__( self.openai = AsyncOpenAI( api_key=_resolve_key(api_key), base_url=base_url or INTERFAZE_BASE_URL, - default_headers=_build_headers(default_headers, show_additional_info, bypass_moe, bypass_cache, admin_key), + default_headers=_build_headers( + default_headers, show_additional_info, bypass_moe, bypass_cache, admin_key + ), **kwargs, ) self.chat = AsyncChat(self.openai) diff --git a/src/interfaze/_constants.py b/src/interfaze/_constants.py index 044b6cc..3f50fef 100644 --- a/src/interfaze/_constants.py +++ b/src/interfaze/_constants.py @@ -16,9 +16,23 @@ # Guardrail categories (ALL enables everything). GUARD_CODES = ( - "S1", "S2", "S3", "S4", "S5", "S6", "S7", - "S8", "S9", "S10", "S11", "S12", "S13", "S14", - "S1_IMAGE", "S12_IMAGE", "S15_IMAGE", + "S1", + "S2", + "S3", + "S4", + "S5", + "S6", + "S7", + "S8", + "S9", + "S10", + "S11", + "S12", + "S13", + "S14", + "S1_IMAGE", + "S12_IMAGE", + "S15_IMAGE", "ALL", ) diff --git a/src/interfaze/_inputs.py b/src/interfaze/_inputs.py index c43069a..9146039 100644 --- a/src/interfaze/_inputs.py +++ b/src/interfaze/_inputs.py @@ -10,14 +10,35 @@ BytesLike = Union[bytes, bytearray] _EXT_MIME = { - "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp", - "gif": "image/gif", "bmp": "image/bmp", "heic": "image/heic", "heif": "image/heif", - "pdf": "application/pdf", "csv": "text/csv", "tsv": "text/tab-separated-values", - "xml": "application/xml", "json": "application/json", "txt": "text/plain", - "md": "text/markdown", "markdown": "text/markdown", "yaml": "application/yaml", "yml": "application/yaml", - "wav": "audio/wav", "mp3": "audio/mpeg", "m4a": "audio/mp4", "ogg": "audio/ogg", "flac": "audio/flac", - "mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm", "avi": "video/x-msvideo", - "mkv": "video/x-matroska", "3gp": "video/3gpp", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "webp": "image/webp", + "gif": "image/gif", + "bmp": "image/bmp", + "heic": "image/heic", + "heif": "image/heif", + "pdf": "application/pdf", + "csv": "text/csv", + "tsv": "text/tab-separated-values", + "xml": "application/xml", + "json": "application/json", + "txt": "text/plain", + "md": "text/markdown", + "markdown": "text/markdown", + "yaml": "application/yaml", + "yml": "application/yaml", + "wav": "audio/wav", + "mp3": "audio/mpeg", + "m4a": "audio/mp4", + "ogg": "audio/ogg", + "flac": "audio/flac", + "mp4": "video/mp4", + "mov": "video/quicktime", + "webm": "video/webm", + "avi": "video/x-msvideo", + "mkv": "video/x-matroska", + "3gp": "video/3gpp", } diff --git a/src/interfaze/_tasks.py b/src/interfaze/_tasks.py index 4fdc1ca..bd73f77 100644 --- a/src/interfaze/_tasks.py +++ b/src/interfaze/_tasks.py @@ -60,7 +60,9 @@ def translate(self, text: str, *, to: str) -> Any: return self._run("translate", f"Translate the following into {to}:\n\n{text}") def forecast(self, csv_source: str, *, periods: int = 10, unit: str = "days") -> Any: - r = self._c.create(messages=[{"role": "user", "content": _forecast_prompt(csv_source, periods, unit)}]) + r = self._c.create( + messages=[{"role": "user", "content": _forecast_prompt(csv_source, periods, unit)}] + ) for p in r.precontext or []: if p.name == "forecast": return p.result @@ -99,7 +101,9 @@ async def translate(self, text: str, *, to: str) -> Any: return await self._run("translate", f"Translate the following into {to}:\n\n{text}") async def forecast(self, csv_source: str, *, periods: int = 10, unit: str = "days") -> Any: - r = await self._c.create(messages=[{"role": "user", "content": _forecast_prompt(csv_source, periods, unit)}]) + r = await self._c.create( + messages=[{"role": "user", "content": _forecast_prompt(csv_source, periods, unit)}] + ) for p in r.precontext or []: if p.name == "forecast": return p.result diff --git a/tests/conftest.py b/tests/conftest.py index 2c96333..8b2eace 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,9 @@ -"""Test fixtures mirroring REAL Interfaze wire responses (observed live). +"""Test fixtures mirroring Interfaze wire responses (observed live). -Kept faithful to the actual shapes: `vcache` always present, `precontext` a list of -{name,result}, task content = {name,result} JSON, json_object content ```json-fenced, +Kept faithful to the actual shapes: `vcache` always present, +`precontext` a list of {name,result}, +task content = {name,result} JSON, +json_object content ```json-fenced, stream deltas role-less. """ @@ -15,7 +17,11 @@ CHAT_URL = "https://api.interfaze.ai/v1/chat/completions" -_USAGE = {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8} +_USAGE = { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, +} def completion( @@ -40,7 +46,8 @@ def completion( BASIC = completion("Hi!") PRECONTEXT = completion( - "Total: $12.34", precontext=[{"name": "ocr", "result": {"extracted_text": "Walmart ... TOTAL 12.34"}}] + "Total: $12.34", + precontext=[{"name": "ocr", "result": {"extracted_text": "Walmart ... TOTAL 12.34"}}], ) # A task entry plus a raw tool-call entry (server-appended on tool/run_code turns). MIXED_PRECONTEXT = completion( @@ -51,7 +58,8 @@ def completion( ], ) REASONING = completion( - "The sky is blue because...", reasoning="Rayleigh scattering means shorter wavelengths..." + "The sky is blue because...", + reasoning="Rayleigh scattering means shorter wavelengths...", ) JSON_OBJECT = completion('```json\n{\n "city": "Tokyo",\n "temp_c": 21\n}\n```') TASK_OCR = completion('{"name": "ocr", "result": {"extracted_text": "See back of receipt", "width": 800}}') diff --git a/tests/test_chat.py b/tests/test_chat.py index da5b32f..6631534 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -109,10 +109,11 @@ def test_precontext_and_vcache_typed(): @respx.mock def test_precontext_tolerates_raw_toolcall_entries(): - # Regression for issue #1: raw tool-call entries in precontext must not raise. + # raw tool-call entries in precontext must not raise. mock_json(MIXED_PRECONTEXT) r = Interfaze(api_key="t").chat.completions.create(messages=[{"role": "user", "content": "run code"}]) assert isinstance(r, InterfazeChatCompletion) + assert r.precontext is not None assert len(r.precontext) == 2 assert r.precontext[0].name == "ocr" and r.precontext[0].result == {"extracted_text": "x"} assert r.precontext[1].name is None @@ -133,6 +134,7 @@ def test_json_object_fence_stripped(): messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"} ) content = r.choices[0].message.content + assert content is not None assert not content.strip().startswith("```") assert json.loads(content)["city"] == "Tokyo"