From d65f4d061a289c95cf32331a916f5b28424a773a Mon Sep 17 00:00:00 2001 From: theQuert Date: Wed, 7 Oct 2026 12:28:40 +0800 Subject: [PATCH 1/2] Stream responses.create responses.create(stream=True), sync and async, sends "stream": true and returns a Stream of ResponseStreamEvent through the shared SSE reader. Events keep their type and every field; the stream ends at [DONE] or at the end of the body. An error event raises the APIError subclass for its code and closes the response. Sync and async now share one request-body builder. Refs #15 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uy5vXsRpMhug7v3XwTMZHv --- README.md | 12 + apertis/resources/responses.py | 215 +++++++++++++----- apertis/types/__init__.py | 2 + apertis/types/responses.py | 22 ++ .../changes/add-responses-stream/proposal.md | 17 ++ .../specs/responses/spec.md | 23 ++ .../changes/add-responses-stream/tasks.md | 6 + tests/test_responses.py | 118 +++++++++- 8 files changed, 363 insertions(+), 52 deletions(-) create mode 100644 openspec/changes/add-responses-stream/proposal.md create mode 100644 openspec/changes/add-responses-stream/specs/responses/spec.md create mode 100644 openspec/changes/add-responses-stream/tasks.md diff --git a/README.md b/README.md index 81eed51..ed159dc 100644 --- a/README.md +++ b/README.md @@ -481,6 +481,16 @@ response = client.responses.create( }], ) +# Streaming: events keep their Responses API type and fields +stream = client.responses.create( + model="gpt-5.4", + input="Write a haiku about programming", + stream=True, +) +for event in stream: + if event.type == "response.output_text.delta": + print(event.delta, end="", flush=True) + # With reasoning response = client.responses.create( model="gpt-5.4", @@ -624,6 +634,8 @@ the `publish` confirmation value. `ResponseInputImage` (`input_image`) and `ResponseInputFile` (`input_file`). The old `text` / `image` part types still type-check. - Add `budget_tokens` to `ThinkingConfig`. +- Add `stream` to `responses.create()`: `stream=True` returns `ResponseStreamEvent` + events, and an `error` event raises `APIError`. ### v0.4.0 - Add `client.audio`: `speech.create()` (binary audio with `write_to_file()`), diff --git a/apertis/resources/responses.py b/apertis/resources/responses.py index 20e4b71..35b8021 100644 --- a/apertis/resources/responses.py +++ b/apertis/resources/responses.py @@ -2,15 +2,51 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Sequence, Union, overload +from apertis._exceptions import _make_api_error +from apertis._streaming import AsyncStream, Stream from apertis.types.chat import CompressionConfig -from apertis.types.responses import Response, ResponseInputItem +from apertis.types.responses import Response, ResponseInputItem, ResponseStreamEvent if TYPE_CHECKING: + import httpx + from apertis._base_client import AsyncClient, SyncClient +# Error codes and types to the HTTP status the same error has outside a stream. +_ERROR_STATUS = { + "invalid_request_error": 400, + "authentication_error": 401, + "permission_error": 403, + "not_found_error": 404, + "rate_limit_exceeded": 429, + "rate_limit_error": 429, + "insufficient_quota": 429, + "server_error": 500, + "api_error": 500, + "overloaded_error": 529, +} + + +def _parse_event(data: Dict[str, Any], response: "httpx.Response") -> ResponseStreamEvent: + """Map one SSE payload to an event; raise on an error event.""" + # OpenAI sends {"type": "error", "code", "message"}; the gateway may send {"error": {...}}. + if data.get("type") == "error" or ("type" not in data and "error" in data): + error = data.get("error") + error = error if isinstance(error, dict) else data + kind = error.get("code") or error.get("type") + raise _make_api_error( + str(error.get("message") or data), + # The stream itself is HTTP 200; the error code says what failed. + status_code=_ERROR_STATUS.get(str(kind), 500), + response=response, + body=data, + ) + return ResponseStreamEvent.model_validate(data) + + class Responses: """Synchronous responses resource. @@ -20,11 +56,66 @@ class Responses: def __init__(self, client: SyncClient) -> None: self._client = client + @overload + def create( + self, + *, + model: str, + input: Union[str, Sequence[ResponseInputItem]], + stream: Literal[True], + instructions: Optional[str] = None, + max_output_tokens: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + reasoning: Optional[Dict[str, Any]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[Union[str, Dict[str, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + compression: Optional[CompressionConfig] = None, + ) -> Stream[ResponseStreamEvent]: ... + + @overload + def create( + self, + *, + model: str, + input: Union[str, Sequence[ResponseInputItem]], + stream: Literal[False] = False, + instructions: Optional[str] = None, + max_output_tokens: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + reasoning: Optional[Dict[str, Any]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[Union[str, Dict[str, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + compression: Optional[CompressionConfig] = None, + ) -> Response: ... + + @overload + def create( + self, + *, + model: str, + input: Union[str, Sequence[ResponseInputItem]], + stream: bool = False, + instructions: Optional[str] = None, + max_output_tokens: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + reasoning: Optional[Dict[str, Any]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[Union[str, Dict[str, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + compression: Optional[CompressionConfig] = None, + ) -> Union[Response, Stream[ResponseStreamEvent]]: ... + def create( self, *, model: str, input: Union[str, Sequence[ResponseInputItem]], + stream: bool = False, instructions: Optional[str] = None, max_output_tokens: Optional[int] = None, temperature: Optional[float] = None, @@ -34,12 +125,13 @@ def create( tool_choice: Optional[Union[str, Dict[str, Any]]] = None, metadata: Optional[Dict[str, str]] = None, compression: Optional[CompressionConfig] = None, - ) -> Response: + ) -> Union[Response, Stream[ResponseStreamEvent]]: """Create a response. Args: model: ID of the model to use (e.g., "gpt-5.4"). input: Input text or structured input items. + stream: If True, returns an iterator of ResponseStreamEvent. instructions: System instructions for the model. max_output_tokens: Maximum tokens to generate. temperature: Sampling temperature (0-2). @@ -51,11 +143,12 @@ def create( compression: Context compression configuration. Returns: - Response object with generated content. + Response, or a Stream of ResponseStreamEvent when stream is True. """ - body = self._build_request_body( + body = _build_request_body( model=model, input=input, + stream=stream, instructions=instructions, max_output_tokens=max_output_tokens, temperature=temperature, @@ -67,54 +160,12 @@ def create( compression=compression, ) + if stream: + return Stream(self._client.stream("POST", "/responses", json=body), _parse_event) + response = self._client.request("POST", "/responses", json=body) return Response.model_validate(response.json()) - def _build_request_body( - self, - *, - model: str, - input: Union[str, Sequence[ResponseInputItem]], - instructions: Optional[str], - max_output_tokens: Optional[int], - temperature: Optional[float], - top_p: Optional[float], - reasoning: Optional[Dict[str, Any]], - tools: Optional[List[Dict[str, Any]]], - tool_choice: Optional[Union[str, Dict[str, Any]]], - metadata: Optional[Dict[str, str]], - compression: Optional[CompressionConfig], - ) -> Dict[str, Any]: - """Build request body for responses.""" - body: Dict[str, Any] = {"model": model} - - # Handle input - can be string or list of input items - if isinstance(input, str): - body["input"] = input - else: - body["input"] = list(input) - - if instructions is not None: - body["instructions"] = instructions - if max_output_tokens is not None: - body["max_output_tokens"] = max_output_tokens - if temperature is not None: - body["temperature"] = temperature - if top_p is not None: - body["top_p"] = top_p - if reasoning is not None: - body["reasoning"] = reasoning - if tools is not None: - body["tools"] = tools - if tool_choice is not None: - body["tool_choice"] = tool_choice - if metadata is not None: - body["metadata"] = metadata - if compression is not None: - body["compression"] = compression - - return body - class AsyncResponses: """Asynchronous responses resource.""" @@ -122,11 +173,66 @@ class AsyncResponses: def __init__(self, client: AsyncClient) -> None: self._client = client + @overload + async def create( + self, + *, + model: str, + input: Union[str, Sequence[ResponseInputItem]], + stream: Literal[True], + instructions: Optional[str] = None, + max_output_tokens: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + reasoning: Optional[Dict[str, Any]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[Union[str, Dict[str, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + compression: Optional[CompressionConfig] = None, + ) -> AsyncStream[ResponseStreamEvent]: ... + + @overload + async def create( + self, + *, + model: str, + input: Union[str, Sequence[ResponseInputItem]], + stream: Literal[False] = False, + instructions: Optional[str] = None, + max_output_tokens: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + reasoning: Optional[Dict[str, Any]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[Union[str, Dict[str, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + compression: Optional[CompressionConfig] = None, + ) -> Response: ... + + @overload + async def create( + self, + *, + model: str, + input: Union[str, Sequence[ResponseInputItem]], + stream: bool = False, + instructions: Optional[str] = None, + max_output_tokens: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + reasoning: Optional[Dict[str, Any]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[Union[str, Dict[str, Any]]] = None, + metadata: Optional[Dict[str, str]] = None, + compression: Optional[CompressionConfig] = None, + ) -> Union[Response, AsyncStream[ResponseStreamEvent]]: ... + async def create( self, *, model: str, input: Union[str, Sequence[ResponseInputItem]], + stream: bool = False, instructions: Optional[str] = None, max_output_tokens: Optional[int] = None, temperature: Optional[float] = None, @@ -136,7 +242,7 @@ async def create( tool_choice: Optional[Union[str, Dict[str, Any]]] = None, metadata: Optional[Dict[str, str]] = None, compression: Optional[CompressionConfig] = None, - ) -> Response: + ) -> Union[Response, AsyncStream[ResponseStreamEvent]]: """Create a response asynchronously. See Responses.create() for parameter documentation. @@ -144,6 +250,7 @@ async def create( body = _build_request_body( model=model, input=input, + stream=stream, instructions=instructions, max_output_tokens=max_output_tokens, temperature=temperature, @@ -155,6 +262,9 @@ async def create( compression=compression, ) + if stream: + return AsyncStream(await self._client.stream("POST", "/responses", json=body), _parse_event) + response = await self._client.request("POST", "/responses", json=body) return Response.model_validate(response.json()) @@ -163,6 +273,7 @@ def _build_request_body( *, model: str, input: Union[str, Sequence[ResponseInputItem]], + stream: bool, instructions: Optional[str], max_output_tokens: Optional[int], temperature: Optional[float], @@ -181,6 +292,8 @@ def _build_request_body( else: body["input"] = list(input) + if stream: + body["stream"] = True if instructions is not None: body["instructions"] = instructions if max_output_tokens is not None: diff --git a/apertis/types/__init__.py b/apertis/types/__init__.py index 34915e1..6544057 100644 --- a/apertis/types/__init__.py +++ b/apertis/types/__init__.py @@ -69,6 +69,7 @@ ResponseReasoningItem, ResponseFunctionToolCall, ResponseUnknownOutputItem, + ResponseStreamEvent, ) from apertis.types.messages import ( Message, @@ -156,6 +157,7 @@ "ResponseReasoningItem", "ResponseFunctionToolCall", "ResponseUnknownOutputItem", + "ResponseStreamEvent", # Messages API "Message", "MessageParam", diff --git a/apertis/types/responses.py b/apertis/types/responses.py index e0090a7..7c74150 100644 --- a/apertis/types/responses.py +++ b/apertis/types/responses.py @@ -147,6 +147,28 @@ def output_text(self) -> str: ) +class ResponseStreamEvent(BaseModel): + """One event of a streamed Responses API call. + + ``type`` names the event (``response.output_text.delta``, ``response.completed``, ...). + Fields not declared here are kept and readable as attributes. Events that the gateway + builds itself carry no ``sequence_number`` or ``item_id``. + """ + + model_config = ConfigDict(extra="allow") + + type: str + sequence_number: Optional[int] = None + item_id: Optional[str] = None + output_index: Optional[int] = None + content_index: Optional[int] = None + delta: Optional[str] = None + text: Optional[str] = None + item: Optional[Dict[str, Any]] = None + part: Optional[Dict[str, Any]] = None + response: Optional[Dict[str, Any]] = None + + # ============================================================================= # Request Parameter Types # ============================================================================= diff --git a/openspec/changes/add-responses-stream/proposal.md b/openspec/changes/add-responses-stream/proposal.md new file mode 100644 index 0000000..8d876fa --- /dev/null +++ b/openspec/changes/add-responses-stream/proposal.md @@ -0,0 +1,17 @@ +## Why + +`responses.create` cannot stream (issue #15), although the gateway streams `/v1/responses`. +It passes OpenAI channels' events through, and builds the same event types itself for other +providers. Callers fall back to the OpenAI SDK. + +## What Changes + +- `responses.create` (sync and async) gains `stream`. +- `stream=True` returns an iterator of `ResponseStreamEvent`. Each event keeps its `type` and + every field the API sent. +- An `error` event raises `APIError`. + +## Impact + +- `apertis/resources/responses.py`, `apertis/types/responses.py`, exports, tests and README. +- Non-streaming calls send the same body as before. diff --git a/openspec/changes/add-responses-stream/specs/responses/spec.md b/openspec/changes/add-responses-stream/specs/responses/spec.md new file mode 100644 index 0000000..85f2a4a --- /dev/null +++ b/openspec/changes/add-responses-stream/specs/responses/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Responses streaming +`responses.create` SHALL accept `stream=True` on sync and async clients, send `"stream": true`, +and return an iterator of `ResponseStreamEvent` in arrival order. Each event SHALL expose `type` +and keep every other field of the event payload. The iterator SHALL end at a `[DONE]` sentinel, +or at the end of the SSE body when there is none. Without `stream`, the request body SHALL be +unchanged. + +#### Scenario: Gateway-built stream +- **WHEN** the API streams `response.created`, `response.output_text.delta` events and + `response.completed`, followed by `data: [DONE]` +- **THEN** the caller receives those events in order, the deltas join to the answer text, and + iteration stops at `[DONE]` + +#### Scenario: Passed-through stream +- **WHEN** the stream ends without `[DONE]` and its events carry `sequence_number`, `item_id` + and fields the SDK does not name +- **THEN** the caller receives every event, with those fields readable + +#### Scenario: Error event +- **WHEN** the stream contains an `error` event +- **THEN** the SDK raises `APIError` carrying the event body and closes the response diff --git a/openspec/changes/add-responses-stream/tasks.md b/openspec/changes/add-responses-stream/tasks.md new file mode 100644 index 0000000..00a8344 --- /dev/null +++ b/openspec/changes/add-responses-stream/tasks.md @@ -0,0 +1,6 @@ +## 1. Responses streaming + +- [x] 1.1 Tests first: synthesized stream with `[DONE]`, passthrough stream without it, error event, unknown fields, async, non-streaming body unchanged +- [x] 1.2 `ResponseStreamEvent` type and export +- [x] 1.3 `stream` on `responses.create` with overloads, using the shared stream reader +- [x] 1.4 README and changelog diff --git a/tests/test_responses.py b/tests/test_responses.py index 135d702..91141fc 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -10,12 +10,13 @@ import respx from pydantic import ValidationError -from apertis import Apertis, AsyncApertis +from apertis import Apertis, AsyncApertis, APIError, RateLimitError from apertis.types.chat import ThinkingConfig from apertis.types.responses import ( Response, ResponseInputContent, ResponseInputItem, + ResponseStreamEvent, ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputRefusal, @@ -326,3 +327,118 @@ def test_input_parts_sent_unchanged(self, client: Apertis) -> None: def test_thinking_budget_tokens_type_checks(self) -> None: thinking: ThinkingConfig = {"type": "enabled", "budget_tokens": 2048} assert thinking["budget_tokens"] == 2048 +def _sse(*events: dict, done: bool = True) -> bytes: + """Encode events as the gateway streams /v1/responses.""" + body = b"".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n".encode() for e in events) + return body + (b"data: [DONE]\n\n" if done else b"") + + +# What the gateway builds for a non-OpenAI channel: no sequence_number or item_id. +_GATEWAY_STREAM = _sse( + {"type": "response.created", "response": {"id": "resp_1", "object": "response", "status": "in_progress"}}, + {"type": "response.output_item.added", "output_index": 0, "item": {"id": "msg_1", "type": "message"}}, + {"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "Hel"}, + {"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "lo"}, + {"type": "response.output_text.done", "output_index": 0, "content_index": 0, "text": "Hello"}, + {"type": "response.completed", "response": {"id": "resp_1", "status": "completed", "output_text": "Hello"}}, +) + + +class TestResponsesStream: + """stream=True yields Responses API stream events.""" + + @respx.mock + def test_gateway_stream_ends_at_done(self, client: Apertis) -> None: + route = respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, content=_GATEWAY_STREAM + _sse({"type": "after.done"}, done=False)) + ) + + stream = client.responses.create(model="claude-sonnet-4.5", input="Hi", stream=True) + events = list(stream) + + assert json.loads(route.calls[0].request.content)["stream"] is True + assert all(isinstance(e, ResponseStreamEvent) for e in events) + assert [e.type for e in events] == [ + "response.created", + "response.output_item.added", + "response.output_text.delta", + "response.output_text.delta", + "response.output_text.done", + "response.completed", + ] + assert "".join(e.delta or "" for e in events if e.type == "response.output_text.delta") == "Hello" + assert events[1].item == {"id": "msg_1", "type": "message"} + assert events[-1].response is not None and events[-1].response["output_text"] == "Hello" + + @respx.mock + def test_passthrough_stream_keeps_all_fields(self, client: Apertis) -> None: + respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response( + 200, + content=_sse( + { + "type": "response.output_text.delta", + "sequence_number": 4, + "item_id": "msg_1", + "output_index": 0, + "content_index": 0, + "delta": "Hi", + "logprobs": [], + }, + {"type": "response.completed", "sequence_number": 5, "response": {"id": "resp_1"}}, + done=False, + ), + ) + ) + + events = list(client.responses.create(model="gpt-5.4", input="Hi", stream=True)) + + assert [e.sequence_number for e in events] == [4, 5] + assert events[0].item_id == "msg_1" + assert events[0].model_dump()["logprobs"] == [] + + @respx.mock + def test_error_event_raises_and_closes(self, client: Apertis) -> None: + error = {"type": "error", "code": "rate_limit_exceeded", "message": "Slow down", "param": None} + respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, content=_sse({"type": "response.created", "response": {}}, error)) + ) + + stream = client.responses.create(model="gpt-5.4", input="Hi", stream=True) + assert next(stream).type == "response.created" + with pytest.raises(RateLimitError, match="Slow down") as exc: + next(stream) + assert exc.value.body == error + assert stream._response.is_closed + + @respx.mock + def test_nested_error_object(self, client: Apertis) -> None: + error = {"error": {"type": "server_error", "message": "upstream failed"}} + respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, content=b"data: " + json.dumps(error).encode() + b"\n\n") + ) + + with pytest.raises(APIError, match="upstream failed"): + list(client.responses.create(model="gpt-5.4", input="Hi", stream=True)) + + @respx.mock + def test_non_streaming_body_has_no_stream_key(self, client: Apertis) -> None: + route = respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, json=_REAL_SHAPE) + ) + + client.responses.create(model="gpt-5.4", input="Hi") + + assert "stream" not in json.loads(route.calls[0].request.content) + + @respx.mock + async def test_async_stream(self, async_client: AsyncApertis) -> None: + route = respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, content=_GATEWAY_STREAM) + ) + + stream = await async_client.responses.create(model="claude-sonnet-4.5", input="Hi", stream=True) + text = "".join([e.delta async for e in stream if e.type == "response.output_text.delta" and e.delta]) + + assert text == "Hello" + assert json.loads(route.calls[0].request.content)["stream"] is True From f889e0cb81471c98f86fb323700fbcf087d59b64 Mon Sep 17 00:00:00 2001 From: theQuert Date: Wed, 7 Oct 2026 12:36:46 +0800 Subject: [PATCH 2/2] Map stream errors by code, then type; keep odd deltas An error whose code is not in the table now falls back to its type, so {"type": "invalid_request_error", "code": "model_not_found"} is a 400, not a 500. delta is typed Any so an event with a non-string delta cannot end the stream. Tests lock response.failed as an ordinary event. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uy5vXsRpMhug7v3XwTMZHv --- apertis/resources/responses.py | 6 +++--- apertis/types/responses.py | 3 ++- tests/test_responses.py | 30 ++++++++++++++++++++++++++++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apertis/resources/responses.py b/apertis/resources/responses.py index 35b8021..be7e72d 100644 --- a/apertis/resources/responses.py +++ b/apertis/resources/responses.py @@ -36,11 +36,11 @@ def _parse_event(data: Dict[str, Any], response: "httpx.Response") -> ResponseSt if data.get("type") == "error" or ("type" not in data and "error" in data): error = data.get("error") error = error if isinstance(error, dict) else data - kind = error.get("code") or error.get("type") + # The stream itself is HTTP 200; the error code or type says what failed. + status = _ERROR_STATUS.get(str(error.get("code"))) or _ERROR_STATUS.get(str(error.get("type")), 500) raise _make_api_error( str(error.get("message") or data), - # The stream itself is HTTP 200; the error code says what failed. - status_code=_ERROR_STATUS.get(str(kind), 500), + status_code=status, response=response, body=data, ) diff --git a/apertis/types/responses.py b/apertis/types/responses.py index 7c74150..26ad17c 100644 --- a/apertis/types/responses.py +++ b/apertis/types/responses.py @@ -162,7 +162,8 @@ class ResponseStreamEvent(BaseModel): item_id: Optional[str] = None output_index: Optional[int] = None content_index: Optional[int] = None - delta: Optional[str] = None + # A string for text, argument and audio deltas; Any so an unforeseen shape cannot end the stream. + delta: Any = None text: Optional[str] = None item: Optional[Dict[str, Any]] = None part: Optional[Dict[str, Any]] = None diff --git a/tests/test_responses.py b/tests/test_responses.py index 91141fc..45c422d 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import List +from typing import Any, Dict, List import httpx import pytest @@ -327,7 +327,7 @@ def test_input_parts_sent_unchanged(self, client: Apertis) -> None: def test_thinking_budget_tokens_type_checks(self) -> None: thinking: ThinkingConfig = {"type": "enabled", "budget_tokens": 2048} assert thinking["budget_tokens"] == 2048 -def _sse(*events: dict, done: bool = True) -> bytes: +def _sse(*events: Dict[str, Any], done: bool = True) -> bytes: """Encode events as the gateway streams /v1/responses.""" body = b"".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n".encode() for e in events) return body + (b"data: [DONE]\n\n" if done else b"") @@ -421,6 +421,32 @@ def test_nested_error_object(self, client: Apertis) -> None: with pytest.raises(APIError, match="upstream failed"): list(client.responses.create(model="gpt-5.4", input="Hi", stream=True)) + @respx.mock + def test_error_type_maps_when_code_is_unknown(self, client: Apertis) -> None: + error = {"error": {"type": "invalid_request_error", "code": "model_not_found", "message": "no model"}} + respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, content=b"data: " + json.dumps(error).encode() + b"\n\n") + ) + + with pytest.raises(APIError) as exc: + list(client.responses.create(model="gpt-5.4", input="Hi", stream=True)) + assert exc.value.status_code == 400 + + @respx.mock + def test_response_failed_is_an_event(self, client: Apertis) -> None: + failed = { + "type": "response.failed", + "response": {"id": "resp_1", "status": "failed", "error": {"code": "server_error", "message": "x"}}, + } + respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, content=_sse(failed, {"type": "response.mcp_call_arguments.delta", "delta": {"a": 1}})) + ) + + events = list(client.responses.create(model="gpt-5.4", input="Hi", stream=True)) + + assert [e.type for e in events] == ["response.failed", "response.mcp_call_arguments.delta"] + assert events[1].delta == {"a": 1} + @respx.mock def test_non_streaming_body_has_no_stream_key(self, client: Apertis) -> None: route = respx.post("https://api.apertis.ai/v1/responses").mock(