Skip to content

Commit 600bddf

Browse files
committed
fix: strip json_object fence on the streaming aggregated path
Non-stream create() unwraps the ```json fence Interfaze wraps json_object content in, but streaming didn't: stream() discarded the strip flag and _State.build() never unwrapped it, so get_final_completion() leaked the fence on json_object streams. Thread the flag from stream() into InterfazeStream/AsyncInterfazeStream and apply strip_json_fence in build(); scoped to the aggregated result (the live delta path is untouched). Moved strip_json_fence into _stream.py (shared with _chat) to avoid a circular import. Forward-compatible with the server-side fix in InterfazeAI/interfaze#218.
1 parent bedf053 commit 600bddf

3 files changed

Lines changed: 67 additions & 22 deletions

File tree

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 {}
@@ -142,8 +131,8 @@ def stream(
142131
response_format: Optional[Dict[str, Any]] = None,
143132
**kwargs: Any,
144133
) -> InterfazeStream:
145-
kw, _ = self._kwargs(messages, model, task, guard, response_format, kwargs)
146-
return InterfazeStream(self._client, kw)
134+
kw, strip = self._kwargs(messages, model, task, guard, response_format, kwargs)
135+
return InterfazeStream(self._client, kw, strip)
147136

148137

149138
class AsyncCompletions(_CompletionsBase):
@@ -207,8 +196,8 @@ def stream(
207196
response_format: Optional[Dict[str, Any]] = None,
208197
**kwargs: Any,
209198
) -> AsyncInterfazeStream:
210-
kw, _ = self._kwargs(messages, model, task, guard, response_format, kwargs)
211-
return AsyncInterfazeStream(self._client, kw)
199+
kw, strip = self._kwargs(messages, model, task, guard, response_format, kwargs)
200+
return AsyncInterfazeStream(self._client, kw, strip)
212201

213202

214203
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
class _State:
3445
def __init__(self) -> None:
3546
self.content = ""
@@ -66,8 +77,10 @@ def accumulate(self, chunk: ChatCompletionChunk) -> None:
6677
if tc.function and tc.function.arguments:
6778
acc["arguments"] += tc.function.arguments
6879

69-
def build(self) -> InterfazeChatCompletion:
80+
def build(self, strip_fence: bool = False) -> InterfazeChatCompletion:
7081
text, reasoning, precontext = strip_side_channels(self.content)
82+
if strip_fence:
83+
text = strip_json_fence(text)
7184
tool_calls = [
7285
{"id": t["id"], "type": "function", "function": {"name": t["name"], "arguments": t["arguments"]}}
7386
for t in self.tool_calls.values()
@@ -95,9 +108,10 @@ def build(self) -> InterfazeChatCompletion:
95108
class InterfazeStream:
96109
"""Sync streaming helper — iterate chunks, then ``get_final_completion()``."""
97110

98-
def __init__(self, client: OpenAI, kwargs: Dict[str, Any]) -> None:
111+
def __init__(self, client: OpenAI, kwargs: Dict[str, Any], strip_fence: bool = False) -> None:
99112
self._client = client
100113
self._kwargs = kwargs
114+
self._strip_fence = strip_fence
101115
self._state = _State()
102116
self._started = False
103117
self._done = False
@@ -131,15 +145,16 @@ def get_final_completion(self) -> InterfazeChatCompletion:
131145
raise InterfazeError(
132146
"Call get_final_completion() after fully iterating, or instead of iterating."
133147
)
134-
return self._state.build()
148+
return self._state.build(self._strip_fence)
135149

136150

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

140-
def __init__(self, client: AsyncOpenAI, kwargs: Dict[str, Any]) -> None:
154+
def __init__(self, client: AsyncOpenAI, kwargs: Dict[str, Any], strip_fence: bool = False) -> None:
141155
self._client = client
142156
self._kwargs = kwargs
157+
self._strip_fence = strip_fence
143158
self._state = _State()
144159
self._started = False
145160
self._done = False
@@ -171,4 +186,4 @@ async def get_final_completion(self) -> InterfazeChatCompletion:
171186
raise InterfazeError(
172187
"Call get_final_completion() after fully iterating, or instead of iterating."
173188
)
174-
return self._state.build()
189+
return self._state.build(self._strip_fence)

tests/test_stream.py

Lines changed: 42 additions & 1 deletion
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
6-
from conftest import STREAM_CHUNKS, STREAM_THINK, mock_sse
7+
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():
@@ -46,3 +54,36 @@ async def go():
4654
n, final = asyncio.run(go())
4755
assert n == len(STREAM_CHUNKS)
4856
assert final.precontext and final.precontext[0].name == "ocr"
57+
58+
59+
@respx.mock
60+
def test_stream_json_object_strips_fence():
61+
mock_sse(FENCED_JSON)
62+
stream = Interfaze(api_key="t").chat.completions.stream(
63+
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
64+
)
65+
content = stream.get_final_completion().choices[0].message.content or ""
66+
assert not content.lstrip().startswith("```")
67+
assert json.loads(content)["city"] == "Tokyo"
68+
69+
70+
@respx.mock
71+
def test_stream_without_json_object_keeps_fence():
72+
mock_sse(FENCED_JSON)
73+
stream = Interfaze(api_key="t").chat.completions.stream(messages=[{"role": "user", "content": "x"}])
74+
content = stream.get_final_completion().choices[0].message.content or ""
75+
assert content.lstrip().startswith("```")
76+
77+
78+
@respx.mock
79+
def test_async_stream_json_object_strips_fence():
80+
mock_sse(FENCED_JSON)
81+
82+
async def go():
83+
stream = AsyncInterfaze(api_key="t").chat.completions.stream(
84+
messages=[{"role": "user", "content": "x"}], response_format={"type": "json_object"}
85+
)
86+
return await stream.get_final_completion()
87+
88+
content = asyncio.run(go()).choices[0].message.content or ""
89+
assert not content.lstrip().startswith("```")

0 commit comments

Comments
 (0)