diff --git a/README.md b/README.md index 0bfce88..5a0264e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/src/interfaze/_chat.py b/src/interfaze/_chat.py index 82aa4b2..92ce2c9 100644 --- a/src/interfaze/_chat.py +++ b/src/interfaze/_chat.py @@ -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 @@ -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 {} @@ -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): @@ -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: diff --git a/src/interfaze/_stream.py b/src/interfaze/_stream.py index 673aeb2..542d033 100644 --- a/src/interfaze/_stream.py +++ b/src/interfaze/_stream.py @@ -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 = ("", "") _SIDE_CLOSE = {"": "", "": ""} @@ -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() @@ -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 @@ -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: @@ -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 @@ -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) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..cb840ba --- /dev/null +++ b/tests/test_models.py @@ -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") diff --git a/tests/test_stream.py b/tests/test_stream.py index baae232..6bb7d98 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -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(): @@ -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("```")