Skip to content

Commit d41d0d2

Browse files
authored
test: production-harden the suite (error contract, tasks, async, real URLs, coverage gate) (#18)
* 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. * test: omit langchain.py from coverage so the gate survives skipped langchain tests (review #18)
1 parent d37dfe1 commit d41d0d2

11 files changed

Lines changed: 1142 additions & 485 deletions

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,21 @@ dev = [
3131
"ruff>=0.6",
3232
"pydantic>=2",
3333
"langchain-openai>=1.3.5; python_version>='3.10'",
34+
"pytest-cov>=5.0.0",
3435
]
3536

3637
[tool.hatch.build.targets.wheel]
3738
packages = ["src/interfaze"]
3839

3940
[tool.pytest.ini_options]
41+
addopts = "--cov --cov-report=term-missing --cov-fail-under=90"
4042
testpaths = ["tests"]
4143

44+
[tool.coverage.run]
45+
branch = true
46+
source = ["src/interfaze"]
47+
omit = ["src/interfaze/langchain.py"]
48+
4249
[tool.ruff]
4350
line-length = 110
4451

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
@@ -75,6 +75,23 @@ def completion(
7575
],
7676
)
7777

78+
TASK_OBJECT_DETECTION = completion(
79+
'{"name": "object_detection", "result": {"objects": [{"label": "bus", "box": [0, 0, 10, 10]}]}}'
80+
)
81+
TASK_GUI_DETECTION = completion(
82+
'{"name": "gui_detection", "result": {"elements": [{"label": "button", "box": [1, 2, 3, 4]}]}}'
83+
)
84+
TASK_TRANSCRIBE = completion('{"name": "speech_to_text", "result": {"text": "hello world"}}')
85+
TASK_WEB_SEARCH = completion(
86+
'{"name": "web_search", "result": {"results": [{"title": "AI agents", "url": "https://example.com"}]}}'
87+
)
88+
TASK_SCRAPE = completion('{"name": "scraper", "result": {"text": "Hacker News"}}')
89+
TASK_TRANSLATE = completion('{"name": "translate", "result": "Bonjour"}')
90+
FORECAST_PRECONTEXT = completion(
91+
"Here is the forecast.", precontext=[{"name": "forecast", "result": {"forecast": [1, 2, 3]}}]
92+
)
93+
FORECAST_FALLBACK = completion("I couldn't run the forecast tool; here's a manual estimate.")
94+
7895

7996
def _chunk(delta: Dict[str, Any], finish_reason=None) -> Dict[str, Any]:
8097
return {
@@ -99,12 +116,72 @@ def _chunk(delta: Dict[str, Any], finish_reason=None) -> Dict[str, Any]:
99116
_chunk({}, finish_reason="stop"),
100117
]
101118

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

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

107179

180+
def mock_status(status: int, body: Dict[str, Any]) -> respx.Route:
181+
"""Route POST /chat/completions -> an error status with a realistic error body."""
182+
return respx.post(CHAT_URL).mock(return_value=httpx.Response(status, json=body))
183+
184+
108185
def sse_bytes(chunks: List[Dict[str, Any]]) -> bytes:
109186
return ("".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n").encode()
110187

@@ -117,6 +194,11 @@ def mock_sse(chunks: List[Dict[str, Any]]) -> respx.Route:
117194
)
118195

119196

197+
def error_body(message: str, type_: str, code: str) -> Dict[str, Any]:
198+
"""Shape of a real Interfaze error response body."""
199+
return {"error": {"message": message, "type": type_, "code": code}}
200+
201+
120202
def last_body(route: respx.Route) -> Dict[str, Any]:
121203
return json.loads(route.calls.last.request.content)
122204

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
@@ -11,14 +11,18 @@
1111
MIXED_PRECONTEXT,
1212
PRECONTEXT,
1313
REASONING,
14+
STREAM_CHUNKS,
1415
TASK_OCR,
1516
TOOL_CALL,
17+
completion,
1618
last_body,
1719
last_headers,
1820
mock_json,
21+
mock_sse,
1922
)
2023

2124
from interfaze import AsyncInterfaze, Interfaze, InterfazeChatCompletion, InterfazeError
25+
from interfaze._chat import to_interfaze
2226

2327
WEATHER_TOOL = [
2428
{
@@ -114,6 +118,43 @@ def test_control_headers():
114118
assert h["x-show-additional-info"] == "true" and h["x-bypass-cache"] == "true"
115119

116120

121+
@respx.mock
122+
def test_all_control_headers_present():
123+
route = mock_json(BASIC)
124+
Interfaze(
125+
api_key="t",
126+
show_additional_info=True,
127+
bypass_moe=True,
128+
bypass_cache=True,
129+
admin_key="admin-secret",
130+
).chat.completions.create(messages=[{"role": "user", "content": "x"}])
131+
h = last_headers(route)
132+
assert h["x-show-additional-info"] == "true"
133+
assert h["x-bypass-moe"] == "true"
134+
assert h["x-bypass-cache"] == "true"
135+
assert h["x-admin-key"] == "admin-secret"
136+
137+
138+
@respx.mock
139+
def test_default_headers_omit_control_flags_when_unset():
140+
route = mock_json(BASIC)
141+
Interfaze(api_key="t").chat.completions.create(messages=[{"role": "user", "content": "x"}])
142+
h = last_headers(route)
143+
assert "x-show-additional-info" not in h
144+
assert "x-bypass-moe" not in h
145+
assert "x-bypass-cache" not in h
146+
assert "x-admin-key" not in h
147+
148+
149+
@respx.mock
150+
def test_per_request_extra_headers_override_client_default():
151+
route = mock_json(BASIC)
152+
Interfaze(api_key="t", admin_key="client-default").chat.completions.create(
153+
messages=[{"role": "user", "content": "x"}], extra_headers={"x-admin-key": "per-request-override"}
154+
)
155+
assert last_headers(route)["x-admin-key"] == "per-request-override"
156+
157+
117158
@respx.mock
118159
def test_reasoning_effort_on_and_extra_body_forwarded():
119160
route = mock_json(BASIC)
@@ -187,6 +228,61 @@ def test_usage_tokens_surfaced():
187228
assert r.usage.total_tokens == 8
188229

189230

231+
@respx.mock
232+
def test_json_object_requested_but_content_not_fenced_passthrough():
233+
mock_json(completion('{"city": "Tokyo"}'))
234+
r = Interfaze(api_key="t").chat.completions.create(
235+
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
236+
)
237+
assert json.loads(r.choices[0].message.content)["city"] == "Tokyo"
238+
239+
240+
@respx.mock
241+
def test_json_object_with_tool_call_content_none_no_crash():
242+
"""A json_object response_format combined with a tool-call response (content=None) must
243+
not crash the fence-stripping pass — it only strips string content."""
244+
mock_json(TOOL_CALL)
245+
r = Interfaze(api_key="t").chat.completions.create(
246+
messages=[{"role": "user", "content": "weather?"}],
247+
tools=WEATHER_TOOL,
248+
response_format={"type": "json_object"},
249+
)
250+
assert r.choices[0].message.content is None
251+
assert r.choices[0].message.tool_calls[0].function.name == "get_weather"
252+
253+
254+
def test_to_interfaze_tolerates_missing_choices():
255+
"""Defensive parsing: a malformed/edge-case completion with no choices must not crash
256+
fence-stripping — the surrounding try/except in `to_interfaze` swallows the IndexError."""
257+
258+
class FakeRaw:
259+
def model_dump(self):
260+
return {
261+
"id": "x",
262+
"object": "chat.completion",
263+
"created": 1,
264+
"model": "m",
265+
"choices": [],
266+
"vcache": False,
267+
}
268+
269+
result = to_interfaze(FakeRaw(), strip_fence=True)
270+
assert result.choices == []
271+
272+
273+
@respx.mock
274+
def test_create_stream_true_returns_raw_openai_stream():
275+
"""`create(stream=True)` is the low-level escape hatch — it hands back openai's own
276+
Stream of raw chunks, not the InterfazeStream wrapper `.stream()` returns."""
277+
mock_sse(STREAM_CHUNKS)
278+
raw_stream = Interfaze(api_key="t").chat.completions.create(
279+
messages=[{"role": "user", "content": "x"}], stream=True
280+
)
281+
chunks = list(raw_stream)
282+
assert len(chunks) == len(STREAM_CHUNKS)
283+
assert chunks[0].choices[0].delta.content is not None
284+
285+
190286
# ---- async ----
191287
@respx.mock
192288
def test_async_mapping():

0 commit comments

Comments
 (0)