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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ URLs and base64 work; raw `bytes` do **not** (must be base64-encoded — this SD
## Good to know

- Interfaze implements `chat.completions` and `models`; other OpenAI endpoints are not exposed.
- `temperature` ≤ 1, `max_tokens` ≤ 32000, `top_p` ≤ 1 (above → 400). Use `max_tokens` (not
`max_completion_tokens`) to bound output.
- `temperature` ≤ 1, `max_tokens` ≤ 32000, `top_p` ≤ 1 (above → 400). Both `max_tokens` and
`max_completion_tokens` bound output (`max_tokens` wins if both are set).
- `n`, `seed`, `stop`, penalties, `logprobs`, `tool_choice`, `top_k` are ignored by Interfaze.
- The underlying OpenAI client is available at `interfaze.openai`.

Expand Down
21 changes: 5 additions & 16 deletions src/interfaze/_chat.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import re
from typing import Any, Dict, Iterable, List, Literal, Optional, Union, cast, overload

from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
Expand All @@ -10,19 +9,9 @@
from ._errors import InterfazeError
from ._guard import guard_tag
from ._schema import empty_task_schema
from ._stream import AsyncInterfazeStream, InterfazeStream
from ._stream import AsyncInterfazeStream, InterfazeStream, strip_json_fence
from ._types import GuardCode, InterfazeChatCompletion, TaskName

_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)


def strip_json_fence(content: str) -> str:
"""Interfaze wraps ``json_object`` content in a ```json fence; unwrap it."""
t = content.strip()
if not t.startswith("```"):
return content
return _FENCE.sub("", t).strip()


def _is_non_empty_schema(rf: Any) -> bool:
schema = (rf or {}).get("json_schema", {}).get("schema", {}) if isinstance(rf, dict) else {}
Expand Down Expand Up @@ -154,8 +143,8 @@ def stream(
response_format: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> InterfazeStream:
kw, _ = self._kwargs(messages, model, task, guard, response_format, kwargs)
return InterfazeStream(self._client, kw)
kw, strip = self._kwargs(messages, model, task, guard, response_format, kwargs)
return InterfazeStream(self._client, kw, strip)


class AsyncCompletions(_CompletionsBase):
Expand Down Expand Up @@ -219,8 +208,8 @@ def stream(
response_format: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> AsyncInterfazeStream:
kw, _ = self._kwargs(messages, model, task, guard, response_format, kwargs)
return AsyncInterfazeStream(self._client, kw)
kw, strip = self._kwargs(messages, model, task, guard, response_format, kwargs)
return AsyncInterfazeStream(self._client, kw, strip)


class Chat:
Expand Down
28 changes: 22 additions & 6 deletions src/interfaze/_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ def strip_side_channels(content: str) -> Tuple[str, Optional[str], Optional[List
return text.strip(), ("\n".join(thinks) if thinks else None), (pre or None)


_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)


def strip_json_fence(content: str) -> str:
"""Interfaze wraps ``json_object`` content in a ```json fence; unwrap it."""
t = content.strip()
if not t.startswith("```"):
return content
return _FENCE.sub("", t).strip()


_SIDE_OPEN = ("<think>", "<precontext>")
_SIDE_CLOSE = {"<think>": "</think>", "<precontext>": "</precontext>"}

Expand Down Expand Up @@ -127,8 +138,10 @@ def accumulate(self, chunk: ChatCompletionChunk) -> None:
if tc.function and tc.function.arguments:
acc["arguments"] += tc.function.arguments

def build(self) -> InterfazeChatCompletion:
def build(self, strip_fence: bool = False) -> InterfazeChatCompletion:
text, reasoning, precontext = strip_side_channels(self.content)
if strip_fence:
text = strip_json_fence(text)
tool_calls = [
{"id": t["id"], "type": "function", "function": {"name": t["name"], "arguments": t["arguments"]}}
for t in self.tool_calls.values()
Expand Down Expand Up @@ -156,9 +169,10 @@ def build(self) -> InterfazeChatCompletion:
class InterfazeStream:
"""Sync streaming helper — iterate chunks, then ``get_final_completion()``."""

def __init__(self, client: OpenAI, kwargs: Dict[str, Any]) -> None:
def __init__(self, client: OpenAI, kwargs: Dict[str, Any], strip_fence: bool = False) -> None:
self._client = client
self._kwargs = kwargs
self._strip_fence = strip_fence
self._state = _State()
self._started = False
self._done = False
Expand Down Expand Up @@ -203,7 +217,8 @@ def text_deltas(self) -> "Iterator[str]":

@property
def text(self) -> str:
return strip_side_channels(self._state.content)[0]
text = strip_side_channels(self._state.content)[0]
return strip_json_fence(text) if self._strip_fence else text

def get_final_completion(self) -> InterfazeChatCompletion:
if not self._started:
Expand All @@ -215,15 +230,16 @@ def get_final_completion(self) -> InterfazeChatCompletion:
raise InterfazeError(
"Call get_final_completion() after fully iterating, or instead of iterating."
)
return self._state.build()
return self._state.build(self._strip_fence)


class AsyncInterfazeStream:
"""Async streaming helper — ``async for`` chunks, then ``await get_final_completion()``."""

def __init__(self, client: AsyncOpenAI, kwargs: Dict[str, Any]) -> None:
def __init__(self, client: AsyncOpenAI, kwargs: Dict[str, Any], strip_fence: bool = False) -> None:
self._client = client
self._kwargs = kwargs
self._strip_fence = strip_fence
self._state = _State()
self._started = False
self._done = False
Expand Down Expand Up @@ -279,4 +295,4 @@ async def get_final_completion(self) -> InterfazeChatCompletion:
raise InterfazeError(
"Call get_final_completion() after fully iterating, or instead of iterating."
)
return self._state.build()
return self._state.build(self._strip_fence)
41 changes: 41 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

import httpx
import pytest
import respx
from openai import NotFoundError

from interfaze import Interfaze

MODELS_URL = "https://api.interfaze.ai/v1/models"
MODEL = {"id": "interfaze-beta", "object": "model", "owned_by": "interfaze", "name": "Interfaze Beta"}
MODELS_LIST = {"object": "list", "data": [MODEL]}
NOT_FOUND = {
"error": {
"message": "The model 'nope' does not exist",
"type": "invalid_request_error",
"code": "model_not_found",
}
}


@respx.mock
def test_models_list():
respx.get(MODELS_URL).mock(return_value=httpx.Response(200, json=MODELS_LIST))
page = Interfaze(api_key="t").models.list()
assert [m.id for m in page] == ["interfaze-beta"]
assert page.data[0].owned_by == "interfaze"


@respx.mock
def test_models_retrieve():
respx.get(f"{MODELS_URL}/interfaze-beta").mock(return_value=httpx.Response(200, json=MODEL))
m = Interfaze(api_key="t").models.retrieve("interfaze-beta")
assert m.id == "interfaze-beta" and m.owned_by == "interfaze"


@respx.mock
def test_models_retrieve_not_found():
respx.get(f"{MODELS_URL}/nope").mock(return_value=httpx.Response(404, json=NOT_FOUND))
with pytest.raises(NotFoundError):
Interfaze(api_key="t").models.retrieve("nope")
52 changes: 52 additions & 0 deletions tests/test_stream.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
from __future__ import annotations

import asyncio
import json

import respx
from conftest import STREAM_CHUNKS, STREAM_THINK, _chunk, mock_sse

from interfaze import AsyncInterfaze, Interfaze

FENCED_JSON = [
_chunk({"content": "```json\n"}),
_chunk({"content": '{"city": "Tokyo"}'}),
_chunk({"content": "\n```"}),
_chunk({}, finish_reason="stop"),
]


@respx.mock
def test_stream_iterates_and_accumulates():
Expand Down Expand Up @@ -104,3 +112,47 @@ async def go():
return "".join([t async for t in stream.text_deltas()])

assert asyncio.run(go()) == "Total is $12.34"


@respx.mock
def test_stream_json_object_strips_fence():
mock_sse(FENCED_JSON)
stream = Interfaze(api_key="t").chat.completions.stream(
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
)
content = stream.get_final_completion().choices[0].message.content or ""
assert not content.lstrip().startswith("```")
assert json.loads(content)["city"] == "Tokyo"


@respx.mock
def test_stream_text_property_strips_fence_for_json_object():
mock_sse(FENCED_JSON)
stream = Interfaze(api_key="t").chat.completions.stream(
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
)
stream.get_final_completion()
assert not stream.text.lstrip().startswith("```")
assert json.loads(stream.text)["city"] == "Tokyo"


@respx.mock
def test_stream_without_json_object_keeps_fence():
mock_sse(FENCED_JSON)
stream = Interfaze(api_key="t").chat.completions.stream(messages=[{"role": "user", "content": "x"}])
content = stream.get_final_completion().choices[0].message.content or ""
assert content.lstrip().startswith("```")


@respx.mock
def test_async_stream_json_object_strips_fence():
mock_sse(FENCED_JSON)

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

content = asyncio.run(go()).choices[0].message.content or ""
assert not content.lstrip().startswith("```")