Skip to content

Commit 4905c07

Browse files
committed
test: production-harden the suite + coverage gate
- Error contract: status->exception mapping (400-503), retry behavior, and timeouts, sync + async (were completely untested). - All 8 task helpers unit-tested (sync + async): asserts the <task> tag on the wire AND result extraction; forecast precontext/fallback/skip-unrelated paths. - Async parity, streaming failure modes (empty stream, streamed tool calls, double-consume, malformed SSE), input edge cases (from_path), guard/schema. - Real Interfaze/JigsawStack doc asset URLs (tests/assets.py); dropped the fake x.com placeholders. - Coverage gate: pytest-cov + branch coverage + --cov-fail-under=90 (suite hits 100% line+branch). 24 -> 119 tests.
1 parent bedf053 commit 4905c07

12 files changed

Lines changed: 1444 additions & 15 deletions

pyproject.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,26 @@ Homepage = "https://interfaze.ai"
2323
Repository = "https://github.com/interfaze/interfaze-py"
2424

2525
[project.optional-dependencies]
26-
dev = ["pytest>=8", "respx>=0.21", "mypy>=1.11", "ruff>=0.6", "pydantic>=2"]
26+
dev = [
27+
"pytest>=8",
28+
"respx>=0.21",
29+
"mypy>=1.11",
30+
"ruff>=0.6",
31+
"pydantic>=2",
32+
"pytest-cov>=5.0.0",
33+
]
2734

2835
[tool.hatch.build.targets.wheel]
2936
packages = ["src/interfaze"]
3037

3138
[tool.pytest.ini_options]
39+
addopts = "--cov --cov-report=term-missing --cov-fail-under=90"
3240
testpaths = ["tests"]
3341

42+
[tool.coverage.run]
43+
branch = true
44+
source = ["src/interfaze"]
45+
3446
[tool.ruff]
3547
line-length = 110
3648

tests/assets.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Real Interfaze/public asset URLs used across tests instead of fake placeholders.
2+
3+
None of these are fetched in tests — respx intercepts every request — but using real
4+
URLs keeps request bodies representative of actual SDK usage.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
ASSETS = {
10+
"image": "https://jigsawstack.com/preview/vocr-example.jpg",
11+
"audio": "https://jigsawstack.com/preview/stt-example.wav",
12+
"csv": "https://r2public.jigsawstack.com/interfaze/examples/prediction-example.csv",
13+
"scene": "https://raw.githubusercontent.com/ultralytics/yolov5/master/data/images/bus.jpg",
14+
"gui": "https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=1024",
15+
"pdf": "https://arxiv.org/pdf/1706.03762",
16+
"scrape": "https://news.ycombinator.com",
17+
"video": "https://download.samplelib.com/mp4/sample-5s.mp4",
18+
}

tests/conftest.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ def completion(
5959
],
6060
)
6161

62+
TASK_OBJECT_DETECTION = completion(
63+
'{"name": "object_detection", "result": {"objects": [{"label": "bus", "box": [0, 0, 10, 10]}]}}'
64+
)
65+
TASK_GUI_DETECTION = completion(
66+
'{"name": "gui_detection", "result": {"elements": [{"label": "button", "box": [1, 2, 3, 4]}]}}'
67+
)
68+
TASK_TRANSCRIBE = completion('{"name": "speech_to_text", "result": {"text": "hello world"}}')
69+
TASK_WEB_SEARCH = completion(
70+
'{"name": "web_search", "result": {"results": [{"title": "AI agents", "url": "https://example.com"}]}}'
71+
)
72+
TASK_SCRAPE = completion('{"name": "scraper", "result": {"text": "Hacker News"}}')
73+
TASK_TRANSLATE = completion('{"name": "translate", "result": "Bonjour"}')
74+
FORECAST_PRECONTEXT = completion(
75+
"Here is the forecast.", precontext=[{"name": "forecast", "result": {"forecast": [1, 2, 3]}}]
76+
)
77+
FORECAST_FALLBACK = completion("I couldn't run the forecast tool; here's a manual estimate.")
78+
6279

6380
def _chunk(delta: Dict[str, Any], finish_reason=None) -> Dict[str, Any]:
6481
return {
@@ -83,12 +100,72 @@ def _chunk(delta: Dict[str, Any], finish_reason=None) -> Dict[str, Any]:
83100
_chunk({}, finish_reason="stop"),
84101
]
85102

103+
# Tool-call arguments split across chunks, as the wire actually streams them.
104+
STREAM_TOOL_CALL_CHUNKS: List[Dict[str, Any]] = [
105+
_chunk(
106+
{
107+
"tool_calls": [
108+
{
109+
"index": 0,
110+
"id": "call_1",
111+
"type": "function",
112+
"function": {"name": "get_weather", "arguments": '{"ci'},
113+
}
114+
]
115+
}
116+
),
117+
_chunk({"tool_calls": [{"index": 0, "function": {"arguments": 'ty": "Pa'}}]}),
118+
_chunk({"tool_calls": [{"index": 0, "function": {"arguments": 'ris"}'}}]}, finish_reason="tool_calls"),
119+
]
120+
121+
# A <precontext> block with invalid JSON inside — interfaze must swallow it, not crash.
122+
STREAM_MALFORMED_PRECONTEXT_CHUNKS: List[Dict[str, Any]] = [
123+
_chunk({"content": "<precontext>[not valid json]</precontext>"}),
124+
_chunk({"content": "Answer anyway."}, finish_reason="stop"),
125+
]
126+
127+
# A heartbeat/ping chunk with no choices at all, as some SSE proxies emit mid-stream.
128+
STREAM_CHUNK_NO_CHOICES: Dict[str, Any] = {
129+
"id": "req-test",
130+
"object": "chat.completion.chunk",
131+
"created": 1_700_000_000,
132+
"model": "interfaze-beta",
133+
"choices": [],
134+
}
135+
STREAM_ROLE_THEN_CONTENT_CHUNKS: List[Dict[str, Any]] = [
136+
_chunk({"role": "assistant", "content": ""}),
137+
_chunk({"content": "Hi there."}, finish_reason="stop"),
138+
]
139+
140+
# Two parallel tool calls in a single delta — one arrives complete, one still has no arguments.
141+
STREAM_PARALLEL_TOOL_CALL_CHUNKS: List[Dict[str, Any]] = [
142+
_chunk(
143+
{
144+
"tool_calls": [
145+
{
146+
"index": 0,
147+
"id": "call_1",
148+
"type": "function",
149+
"function": {"name": "get_weather", "arguments": '{"city": "Paris"}'},
150+
},
151+
{"index": 1, "id": "call_2", "type": "function", "function": {"name": "get_time"}},
152+
]
153+
},
154+
finish_reason="tool_calls",
155+
),
156+
]
157+
86158

87159
def mock_json(body: Dict[str, Any]) -> respx.Route:
88160
"""Route POST /chat/completions -> a JSON completion; returns the route (inspect .calls)."""
89161
return respx.post(CHAT_URL).mock(return_value=httpx.Response(200, json=body))
90162

91163

164+
def mock_status(status: int, body: Dict[str, Any]) -> respx.Route:
165+
"""Route POST /chat/completions -> an error status with a realistic error body."""
166+
return respx.post(CHAT_URL).mock(return_value=httpx.Response(status, json=body))
167+
168+
92169
def sse_bytes(chunks: List[Dict[str, Any]]) -> bytes:
93170
return ("".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n").encode()
94171

@@ -101,6 +178,11 @@ def mock_sse(chunks: List[Dict[str, Any]]) -> respx.Route:
101178
)
102179

103180

181+
def error_body(message: str, type_: str, code: str) -> Dict[str, Any]:
182+
"""Shape of a real Interfaze error response body."""
183+
return {"error": {"message": message, "type": type_, "code": code}}
184+
185+
104186
def last_body(route: respx.Route) -> Dict[str, Any]:
105187
return json.loads(route.calls.last.request.content)
106188

tests/test_async.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import json
5+
6+
import pytest
7+
import respx
8+
from conftest import JSON_OBJECT, STREAM_CHUNKS, TASK_OCR, TOOL_CALL, last_body, mock_json, mock_sse
9+
10+
from interfaze import AsyncInterfaze, InterfazeError
11+
12+
WEATHER_TOOL = [
13+
{
14+
"type": "function",
15+
"function": {
16+
"name": "get_weather",
17+
"parameters": {
18+
"type": "object",
19+
"properties": {"city": {"type": "string"}},
20+
"required": ["city"],
21+
},
22+
},
23+
}
24+
]
25+
26+
27+
@respx.mock
28+
def test_async_task_and_guard_serialization():
29+
route = mock_json(TASK_OCR)
30+
31+
async def go():
32+
return await AsyncInterfaze(api_key="t").chat.completions.create(
33+
task="ocr", guard=["S1", "ALL"], messages=[{"role": "user", "content": "x"}]
34+
)
35+
36+
asyncio.run(go())
37+
system_content = last_body(route)["messages"][0]["content"]
38+
assert "<task>ocr</task>" in system_content
39+
assert "<guard>S1, ALL</guard>" in system_content
40+
41+
42+
@respx.mock
43+
def test_async_tool_calls_content_none():
44+
mock_json(TOOL_CALL)
45+
46+
async def go():
47+
return await AsyncInterfaze(api_key="t").chat.completions.create(
48+
messages=[{"role": "user", "content": "weather?"}], tools=WEATHER_TOOL, tool_choice="auto"
49+
)
50+
51+
r = asyncio.run(go())
52+
assert r.choices[0].finish_reason == "tool_calls"
53+
assert r.choices[0].message.content is None
54+
assert r.choices[0].message.tool_calls[0].function.name == "get_weather"
55+
56+
57+
@respx.mock
58+
def test_async_json_object_fence_stripped():
59+
mock_json(JSON_OBJECT)
60+
61+
async def go():
62+
return await AsyncInterfaze(api_key="t").chat.completions.create(
63+
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
64+
)
65+
66+
r = asyncio.run(go())
67+
content = r.choices[0].message.content
68+
assert not content.strip().startswith("```")
69+
assert json.loads(content)["city"] == "Tokyo"
70+
71+
72+
def test_async_missing_key_raises(monkeypatch):
73+
monkeypatch.delenv("INTERFAZE_API_KEY", raising=False)
74+
with pytest.raises(InterfazeError, match="INTERFAZE_API_KEY"):
75+
AsyncInterfaze()
76+
77+
78+
@respx.mock
79+
def test_async_create_stream_true_returns_raw_openai_stream():
80+
mock_sse(STREAM_CHUNKS)
81+
82+
async def go():
83+
raw_stream = await AsyncInterfaze(api_key="t").chat.completions.create(
84+
messages=[{"role": "user", "content": "x"}], stream=True
85+
)
86+
return [chunk async for chunk in raw_stream]
87+
88+
chunks = asyncio.run(go())
89+
assert len(chunks) == len(STREAM_CHUNKS)
90+
assert chunks[0].choices[0].delta.content is not None

tests/test_chat.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010
JSON_OBJECT,
1111
PRECONTEXT,
1212
REASONING,
13+
STREAM_CHUNKS,
1314
TASK_OCR,
1415
TOOL_CALL,
16+
completion,
1517
last_body,
1618
last_headers,
1719
mock_json,
20+
mock_sse,
1821
)
1922

2023
from interfaze import AsyncInterfaze, Interfaze, InterfazeChatCompletion, InterfazeError
24+
from interfaze._chat import to_interfaze
2125

2226
WEATHER_TOOL = [
2327
{
@@ -86,6 +90,43 @@ def test_control_headers():
8690
assert h["x-show-additional-info"] == "true" and h["x-bypass-cache"] == "true"
8791

8892

93+
@respx.mock
94+
def test_all_control_headers_present():
95+
route = mock_json(BASIC)
96+
Interfaze(
97+
api_key="t",
98+
show_additional_info=True,
99+
bypass_moe=True,
100+
bypass_cache=True,
101+
admin_key="admin-secret",
102+
).chat.completions.create(messages=[{"role": "user", "content": "x"}])
103+
h = last_headers(route)
104+
assert h["x-show-additional-info"] == "true"
105+
assert h["x-bypass-moe"] == "true"
106+
assert h["x-bypass-cache"] == "true"
107+
assert h["x-admin-key"] == "admin-secret"
108+
109+
110+
@respx.mock
111+
def test_default_headers_omit_control_flags_when_unset():
112+
route = mock_json(BASIC)
113+
Interfaze(api_key="t").chat.completions.create(messages=[{"role": "user", "content": "x"}])
114+
h = last_headers(route)
115+
assert "x-show-additional-info" not in h
116+
assert "x-bypass-moe" not in h
117+
assert "x-bypass-cache" not in h
118+
assert "x-admin-key" not in h
119+
120+
121+
@respx.mock
122+
def test_per_request_extra_headers_override_client_default():
123+
route = mock_json(BASIC)
124+
Interfaze(api_key="t", admin_key="client-default").chat.completions.create(
125+
messages=[{"role": "user", "content": "x"}], extra_headers={"x-admin-key": "per-request-override"}
126+
)
127+
assert last_headers(route)["x-admin-key"] == "per-request-override"
128+
129+
89130
@respx.mock
90131
def test_reasoning_effort_on_and_extra_body_forwarded():
91132
route = mock_json(BASIC)
@@ -135,6 +176,61 @@ def test_tools_content_none():
135176
assert r.choices[0].message.tool_calls[0].function.name == "get_weather"
136177

137178

179+
@respx.mock
180+
def test_json_object_requested_but_content_not_fenced_passthrough():
181+
mock_json(completion('{"city": "Tokyo"}'))
182+
r = Interfaze(api_key="t").chat.completions.create(
183+
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
184+
)
185+
assert json.loads(r.choices[0].message.content)["city"] == "Tokyo"
186+
187+
188+
@respx.mock
189+
def test_json_object_with_tool_call_content_none_no_crash():
190+
"""A json_object response_format combined with a tool-call response (content=None) must
191+
not crash the fence-stripping pass — it only strips string content."""
192+
mock_json(TOOL_CALL)
193+
r = Interfaze(api_key="t").chat.completions.create(
194+
messages=[{"role": "user", "content": "weather?"}],
195+
tools=WEATHER_TOOL,
196+
response_format={"type": "json_object"},
197+
)
198+
assert r.choices[0].message.content is None
199+
assert r.choices[0].message.tool_calls[0].function.name == "get_weather"
200+
201+
202+
def test_to_interfaze_tolerates_missing_choices():
203+
"""Defensive parsing: a malformed/edge-case completion with no choices must not crash
204+
fence-stripping — the surrounding try/except in `to_interfaze` swallows the IndexError."""
205+
206+
class FakeRaw:
207+
def model_dump(self):
208+
return {
209+
"id": "x",
210+
"object": "chat.completion",
211+
"created": 1,
212+
"model": "m",
213+
"choices": [],
214+
"vcache": False,
215+
}
216+
217+
result = to_interfaze(FakeRaw(), strip_fence=True)
218+
assert result.choices == []
219+
220+
221+
@respx.mock
222+
def test_create_stream_true_returns_raw_openai_stream():
223+
"""`create(stream=True)` is the low-level escape hatch — it hands back openai's own
224+
Stream of raw chunks, not the InterfazeStream wrapper `.stream()` returns."""
225+
mock_sse(STREAM_CHUNKS)
226+
raw_stream = Interfaze(api_key="t").chat.completions.create(
227+
messages=[{"role": "user", "content": "x"}], stream=True
228+
)
229+
chunks = list(raw_stream)
230+
assert len(chunks) == len(STREAM_CHUNKS)
231+
assert chunks[0].choices[0].delta.content is not None
232+
233+
138234
# ---- async ----
139235
@respx.mock
140236
def test_async_mapping():

0 commit comments

Comments
 (0)