Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,21 @@ dev = [
"ruff>=0.6",
"pydantic>=2",
"langchain-openai>=1.3.5; python_version>='3.10'",
"pytest-cov>=5.0.0",
]

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

[tool.pytest.ini_options]
addopts = "--cov --cov-report=term-missing --cov-fail-under=90"
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["src/interfaze"]
Comment thread
Abhinavexist marked this conversation as resolved.
omit = ["src/interfaze/langchain.py"]

[tool.ruff]
line-length = 110

Expand Down
18 changes: 18 additions & 0 deletions tests/assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Real Interfaze/public asset URLs used across tests instead of fake placeholders.

None of these are fetched in tests — respx intercepts every request — but using real
URLs keeps request bodies representative of actual SDK usage.
"""

from __future__ import annotations

ASSETS = {
"image": "https://jigsawstack.com/preview/vocr-example.jpg",
"audio": "https://jigsawstack.com/preview/stt-example.wav",
"csv": "https://r2public.jigsawstack.com/interfaze/examples/prediction-example.csv",
"scene": "https://raw.githubusercontent.com/ultralytics/yolov5/master/data/images/bus.jpg",
"gui": "https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=1024",
"pdf": "https://arxiv.org/pdf/1706.03762",
"scrape": "https://news.ycombinator.com",
"video": "https://download.samplelib.com/mp4/sample-5s.mp4",
}
82 changes: 82 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ def completion(
],
)

TASK_OBJECT_DETECTION = completion(
'{"name": "object_detection", "result": {"objects": [{"label": "bus", "box": [0, 0, 10, 10]}]}}'
)
TASK_GUI_DETECTION = completion(
'{"name": "gui_detection", "result": {"elements": [{"label": "button", "box": [1, 2, 3, 4]}]}}'
)
TASK_TRANSCRIBE = completion('{"name": "speech_to_text", "result": {"text": "hello world"}}')
TASK_WEB_SEARCH = completion(
'{"name": "web_search", "result": {"results": [{"title": "AI agents", "url": "https://example.com"}]}}'
)
TASK_SCRAPE = completion('{"name": "scraper", "result": {"text": "Hacker News"}}')
TASK_TRANSLATE = completion('{"name": "translate", "result": "Bonjour"}')
FORECAST_PRECONTEXT = completion(
"Here is the forecast.", precontext=[{"name": "forecast", "result": {"forecast": [1, 2, 3]}}]
)
FORECAST_FALLBACK = completion("I couldn't run the forecast tool; here's a manual estimate.")


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

# Tool-call arguments split across chunks, as the wire actually streams them.
STREAM_TOOL_CALL_CHUNKS: List[Dict[str, Any]] = [
_chunk(
{
"tool_calls": [
{
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"ci'},
}
]
}
),
_chunk({"tool_calls": [{"index": 0, "function": {"arguments": 'ty": "Pa'}}]}),
_chunk({"tool_calls": [{"index": 0, "function": {"arguments": 'ris"}'}}]}, finish_reason="tool_calls"),
]

# A <precontext> block with invalid JSON inside — interfaze must swallow it, not crash.
STREAM_MALFORMED_PRECONTEXT_CHUNKS: List[Dict[str, Any]] = [
_chunk({"content": "<precontext>[not valid json]</precontext>"}),
_chunk({"content": "Answer anyway."}, finish_reason="stop"),
]

# A heartbeat/ping chunk with no choices at all, as some SSE proxies emit mid-stream.
STREAM_CHUNK_NO_CHOICES: Dict[str, Any] = {
"id": "req-test",
"object": "chat.completion.chunk",
"created": 1_700_000_000,
"model": "interfaze-beta",
"choices": [],
}
STREAM_ROLE_THEN_CONTENT_CHUNKS: List[Dict[str, Any]] = [
_chunk({"role": "assistant", "content": ""}),
_chunk({"content": "Hi there."}, finish_reason="stop"),
]

# Two parallel tool calls in a single delta — one arrives complete, one still has no arguments.
STREAM_PARALLEL_TOOL_CALL_CHUNKS: List[Dict[str, Any]] = [
_chunk(
{
"tool_calls": [
{
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city": "Paris"}'},
},
{"index": 1, "id": "call_2", "type": "function", "function": {"name": "get_time"}},
]
},
finish_reason="tool_calls",
),
]


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


def mock_status(status: int, body: Dict[str, Any]) -> respx.Route:
"""Route POST /chat/completions -> an error status with a realistic error body."""
return respx.post(CHAT_URL).mock(return_value=httpx.Response(status, json=body))


def sse_bytes(chunks: List[Dict[str, Any]]) -> bytes:
return ("".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n").encode()

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


def error_body(message: str, type_: str, code: str) -> Dict[str, Any]:
"""Shape of a real Interfaze error response body."""
return {"error": {"message": message, "type": type_, "code": code}}


def last_body(route: respx.Route) -> Dict[str, Any]:
return json.loads(route.calls.last.request.content)

Expand Down
90 changes: 90 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations

import asyncio
import json

import pytest
import respx
from conftest import JSON_OBJECT, STREAM_CHUNKS, TASK_OCR, TOOL_CALL, last_body, mock_json, mock_sse

from interfaze import AsyncInterfaze, InterfazeError

WEATHER_TOOL = [
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
]


@respx.mock
def test_async_task_and_guard_serialization():
route = mock_json(TASK_OCR)

async def go():
return await AsyncInterfaze(api_key="t").chat.completions.create(
task="ocr", guard=["S1", "ALL"], messages=[{"role": "user", "content": "x"}]
)

asyncio.run(go())
system_content = last_body(route)["messages"][0]["content"]
assert "<task>ocr</task>" in system_content
assert "<guard>S1, ALL</guard>" in system_content


@respx.mock
def test_async_tool_calls_content_none():
mock_json(TOOL_CALL)

async def go():
return await AsyncInterfaze(api_key="t").chat.completions.create(
messages=[{"role": "user", "content": "weather?"}], tools=WEATHER_TOOL, tool_choice="auto"
)

r = asyncio.run(go())
assert r.choices[0].finish_reason == "tool_calls"
assert r.choices[0].message.content is None
assert r.choices[0].message.tool_calls[0].function.name == "get_weather"


@respx.mock
def test_async_json_object_fence_stripped():
mock_json(JSON_OBJECT)

async def go():
return await AsyncInterfaze(api_key="t").chat.completions.create(
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
)

r = asyncio.run(go())
content = r.choices[0].message.content
assert not content.strip().startswith("```")
assert json.loads(content)["city"] == "Tokyo"


def test_async_missing_key_raises(monkeypatch):
monkeypatch.delenv("INTERFAZE_API_KEY", raising=False)
with pytest.raises(InterfazeError, match="INTERFAZE_API_KEY"):
AsyncInterfaze()


@respx.mock
def test_async_create_stream_true_returns_raw_openai_stream():
mock_sse(STREAM_CHUNKS)

async def go():
raw_stream = await AsyncInterfaze(api_key="t").chat.completions.create(
messages=[{"role": "user", "content": "x"}], stream=True
)
return [chunk async for chunk in raw_stream]

chunks = asyncio.run(go())
assert len(chunks) == len(STREAM_CHUNKS)
assert chunks[0].choices[0].delta.content is not None
96 changes: 96 additions & 0 deletions tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@
MIXED_PRECONTEXT,
PRECONTEXT,
REASONING,
STREAM_CHUNKS,
TASK_OCR,
TOOL_CALL,
completion,
last_body,
last_headers,
mock_json,
mock_sse,
)

from interfaze import AsyncInterfaze, Interfaze, InterfazeChatCompletion, InterfazeError
from interfaze._chat import to_interfaze

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


@respx.mock
def test_all_control_headers_present():
route = mock_json(BASIC)
Interfaze(
api_key="t",
show_additional_info=True,
bypass_moe=True,
bypass_cache=True,
admin_key="admin-secret",
).chat.completions.create(messages=[{"role": "user", "content": "x"}])
h = last_headers(route)
assert h["x-show-additional-info"] == "true"
assert h["x-bypass-moe"] == "true"
assert h["x-bypass-cache"] == "true"
assert h["x-admin-key"] == "admin-secret"


@respx.mock
def test_default_headers_omit_control_flags_when_unset():
route = mock_json(BASIC)
Interfaze(api_key="t").chat.completions.create(messages=[{"role": "user", "content": "x"}])
h = last_headers(route)
assert "x-show-additional-info" not in h
assert "x-bypass-moe" not in h
assert "x-bypass-cache" not in h
assert "x-admin-key" not in h


@respx.mock
def test_per_request_extra_headers_override_client_default():
route = mock_json(BASIC)
Interfaze(api_key="t", admin_key="client-default").chat.completions.create(
messages=[{"role": "user", "content": "x"}], extra_headers={"x-admin-key": "per-request-override"}
)
assert last_headers(route)["x-admin-key"] == "per-request-override"


@respx.mock
def test_reasoning_effort_on_and_extra_body_forwarded():
route = mock_json(BASIC)
Expand Down Expand Up @@ -187,6 +228,61 @@ def test_usage_tokens_surfaced():
assert r.usage.total_tokens == 8


@respx.mock
def test_json_object_requested_but_content_not_fenced_passthrough():
mock_json(completion('{"city": "Tokyo"}'))
r = Interfaze(api_key="t").chat.completions.create(
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
)
assert json.loads(r.choices[0].message.content)["city"] == "Tokyo"


@respx.mock
def test_json_object_with_tool_call_content_none_no_crash():
"""A json_object response_format combined with a tool-call response (content=None) must
not crash the fence-stripping pass — it only strips string content."""
mock_json(TOOL_CALL)
r = Interfaze(api_key="t").chat.completions.create(
messages=[{"role": "user", "content": "weather?"}],
tools=WEATHER_TOOL,
response_format={"type": "json_object"},
)
assert r.choices[0].message.content is None
assert r.choices[0].message.tool_calls[0].function.name == "get_weather"


def test_to_interfaze_tolerates_missing_choices():
"""Defensive parsing: a malformed/edge-case completion with no choices must not crash
fence-stripping — the surrounding try/except in `to_interfaze` swallows the IndexError."""

class FakeRaw:
def model_dump(self):
return {
"id": "x",
"object": "chat.completion",
"created": 1,
"model": "m",
"choices": [],
"vcache": False,
}

result = to_interfaze(FakeRaw(), strip_fence=True)
assert result.choices == []


@respx.mock
def test_create_stream_true_returns_raw_openai_stream():
"""`create(stream=True)` is the low-level escape hatch — it hands back openai's own
Stream of raw chunks, not the InterfazeStream wrapper `.stream()` returns."""
mock_sse(STREAM_CHUNKS)
raw_stream = Interfaze(api_key="t").chat.completions.create(
messages=[{"role": "user", "content": "x"}], stream=True
)
chunks = list(raw_stream)
assert len(chunks) == len(STREAM_CHUNKS)
assert chunks[0].choices[0].delta.content is not None


# ---- async ----
@respx.mock
def test_async_mapping():
Expand Down
Loading
Loading