Skip to content

Commit b7b4643

Browse files
committed
fix: align SDK with server #218 (max_completion_tokens, models.retrieve, streaming fence strip)
1 parent 309dc3b commit b7b4643

5 files changed

Lines changed: 109 additions & 23 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ URLs and base64 work; raw `bytes` do **not** (must be base64-encoded — this SD
124124
## Good to know
125125

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

‎src/interfaze/_chat.py‎

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import re
43
from typing import Any, Dict, Iterable, List, Literal, Optional, Union, cast, overload
54

65
from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
@@ -10,19 +9,9 @@
109
from ._errors import InterfazeError
1110
from ._guard import guard_tag
1211
from ._schema import empty_task_schema
13-
from ._stream import AsyncInterfazeStream, InterfazeStream
12+
from ._stream import AsyncInterfazeStream, InterfazeStream, strip_json_fence
1413
from ._types import GuardCode, InterfazeChatCompletion, TaskName
1514

16-
_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
17-
18-
19-
def strip_json_fence(content: str) -> str:
20-
"""Interfaze wraps ``json_object`` content in a ```json fence; unwrap it."""
21-
t = content.strip()
22-
if not t.startswith("```"):
23-
return content
24-
return _FENCE.sub("", t).strip()
25-
2615

2716
def _is_non_empty_schema(rf: Any) -> bool:
2817
schema = (rf or {}).get("json_schema", {}).get("schema", {}) if isinstance(rf, dict) else {}
@@ -154,8 +143,8 @@ def stream(
154143
response_format: Optional[Dict[str, Any]] = None,
155144
**kwargs: Any,
156145
) -> InterfazeStream:
157-
kw, _ = self._kwargs(messages, model, task, guard, response_format, kwargs)
158-
return InterfazeStream(self._client, kw)
146+
kw, strip = self._kwargs(messages, model, task, guard, response_format, kwargs)
147+
return InterfazeStream(self._client, kw, strip)
159148

160149

161150
class AsyncCompletions(_CompletionsBase):
@@ -219,8 +208,8 @@ def stream(
219208
response_format: Optional[Dict[str, Any]] = None,
220209
**kwargs: Any,
221210
) -> AsyncInterfazeStream:
222-
kw, _ = self._kwargs(messages, model, task, guard, response_format, kwargs)
223-
return AsyncInterfazeStream(self._client, kw)
211+
kw, strip = self._kwargs(messages, model, task, guard, response_format, kwargs)
212+
return AsyncInterfazeStream(self._client, kw, strip)
224213

225214

226215
class Chat:

‎src/interfaze/_stream.py‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ def strip_side_channels(content: str) -> Tuple[str, Optional[str], Optional[List
3030
return text.strip(), ("\n".join(thinks) if thinks else None), (pre or None)
3131

3232

33+
_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
34+
35+
36+
def strip_json_fence(content: str) -> str:
37+
"""Interfaze wraps ``json_object`` content in a ```json fence; unwrap it."""
38+
t = content.strip()
39+
if not t.startswith("```"):
40+
return content
41+
return _FENCE.sub("", t).strip()
42+
43+
3344
_SIDE_OPEN = ("<think>", "<precontext>")
3445
_SIDE_CLOSE = {"<think>": "</think>", "<precontext>": "</precontext>"}
3546

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

130-
def build(self) -> InterfazeChatCompletion:
141+
def build(self, strip_fence: bool = False) -> InterfazeChatCompletion:
131142
text, reasoning, precontext = strip_side_channels(self.content)
143+
if strip_fence:
144+
text = strip_json_fence(text)
132145
tool_calls = [
133146
{"id": t["id"], "type": "function", "function": {"name": t["name"], "arguments": t["arguments"]}}
134147
for t in self.tool_calls.values()
@@ -156,9 +169,10 @@ def build(self) -> InterfazeChatCompletion:
156169
class InterfazeStream:
157170
"""Sync streaming helper — iterate chunks, then ``get_final_completion()``."""
158171

159-
def __init__(self, client: OpenAI, kwargs: Dict[str, Any]) -> None:
172+
def __init__(self, client: OpenAI, kwargs: Dict[str, Any], strip_fence: bool = False) -> None:
160173
self._client = client
161174
self._kwargs = kwargs
175+
self._strip_fence = strip_fence
162176
self._state = _State()
163177
self._started = False
164178
self._done = False
@@ -215,15 +229,16 @@ def get_final_completion(self) -> InterfazeChatCompletion:
215229
raise InterfazeError(
216230
"Call get_final_completion() after fully iterating, or instead of iterating."
217231
)
218-
return self._state.build()
232+
return self._state.build(self._strip_fence)
219233

220234

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

224-
def __init__(self, client: AsyncOpenAI, kwargs: Dict[str, Any]) -> None:
238+
def __init__(self, client: AsyncOpenAI, kwargs: Dict[str, Any], strip_fence: bool = False) -> None:
225239
self._client = client
226240
self._kwargs = kwargs
241+
self._strip_fence = strip_fence
227242
self._state = _State()
228243
self._started = False
229244
self._done = False
@@ -279,4 +294,4 @@ async def get_final_completion(self) -> InterfazeChatCompletion:
279294
raise InterfazeError(
280295
"Call get_final_completion() after fully iterating, or instead of iterating."
281296
)
282-
return self._state.build()
297+
return self._state.build(self._strip_fence)

‎tests/test_models.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import annotations
2+
3+
import httpx
4+
import pytest
5+
import respx
6+
from openai import NotFoundError
7+
8+
from interfaze import Interfaze
9+
10+
MODELS_URL = "https://api.interfaze.ai/v1/models"
11+
MODEL = {"id": "interfaze-beta", "object": "model", "owned_by": "interfaze", "name": "Interfaze Beta"}
12+
MODELS_LIST = {"object": "list", "data": [MODEL]}
13+
NOT_FOUND = {
14+
"error": {
15+
"message": "The model 'nope' does not exist",
16+
"type": "invalid_request_error",
17+
"code": "model_not_found",
18+
}
19+
}
20+
21+
22+
@respx.mock
23+
def test_models_list():
24+
respx.get(MODELS_URL).mock(return_value=httpx.Response(200, json=MODELS_LIST))
25+
page = Interfaze(api_key="t").models.list()
26+
assert [m.id for m in page] == ["interfaze-beta"]
27+
assert page.data[0].owned_by == "interfaze"
28+
29+
30+
@respx.mock
31+
def test_models_retrieve():
32+
respx.get(f"{MODELS_URL}/interfaze-beta").mock(return_value=httpx.Response(200, json=MODEL))
33+
m = Interfaze(api_key="t").models.retrieve("interfaze-beta")
34+
assert m.id == "interfaze-beta" and m.owned_by == "interfaze"
35+
36+
37+
@respx.mock
38+
def test_models_retrieve_not_found():
39+
respx.get(f"{MODELS_URL}/nope").mock(return_value=httpx.Response(404, json=NOT_FOUND))
40+
with pytest.raises(NotFoundError):
41+
Interfaze(api_key="t").models.retrieve("nope")

‎tests/test_stream.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import json
45

56
import respx
67
from conftest import STREAM_CHUNKS, STREAM_THINK, _chunk, mock_sse
78

89
from interfaze import AsyncInterfaze, Interfaze
910

11+
FENCED_JSON = [
12+
_chunk({"content": "```json\n"}),
13+
_chunk({"content": '{"city": "Tokyo"}'}),
14+
_chunk({"content": "\n```"}),
15+
_chunk({}, finish_reason="stop"),
16+
]
17+
1018

1119
@respx.mock
1220
def test_stream_iterates_and_accumulates():
@@ -104,3 +112,36 @@ async def go():
104112
return "".join([t async for t in stream.text_deltas()])
105113

106114
assert asyncio.run(go()) == "Total is $12.34"
115+
116+
117+
@respx.mock
118+
def test_stream_json_object_strips_fence():
119+
mock_sse(FENCED_JSON)
120+
stream = Interfaze(api_key="t").chat.completions.stream(
121+
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
122+
)
123+
content = stream.get_final_completion().choices[0].message.content or ""
124+
assert not content.lstrip().startswith("```")
125+
assert json.loads(content)["city"] == "Tokyo"
126+
127+
128+
@respx.mock
129+
def test_stream_without_json_object_keeps_fence():
130+
mock_sse(FENCED_JSON)
131+
stream = Interfaze(api_key="t").chat.completions.stream(messages=[{"role": "user", "content": "x"}])
132+
content = stream.get_final_completion().choices[0].message.content or ""
133+
assert content.lstrip().startswith("```")
134+
135+
136+
@respx.mock
137+
def test_async_stream_json_object_strips_fence():
138+
mock_sse(FENCED_JSON)
139+
140+
async def go():
141+
stream = AsyncInterfaze(api_key="t").chat.completions.stream(
142+
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
143+
)
144+
return await stream.get_final_completion()
145+
146+
content = asyncio.run(go()).choices[0].message.content or ""
147+
assert not content.lstrip().startswith("```")

0 commit comments

Comments
 (0)