Skip to content
Open
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
24 changes: 24 additions & 0 deletions chatmock/responses_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import copy
import json
from dataclasses import dataclass
from typing import Any, Dict, Iterable, Iterator, List
Expand Down Expand Up @@ -171,6 +172,8 @@ def aggregate_response_from_sse(
) -> tuple[Dict[str, Any] | None, Dict[str, Any] | None]:
response_obj: Dict[str, Any] | None = None
error_obj: Dict[str, Any] | None = None
completed_output_items: Dict[int, Dict[str, Any]] = {}
unindexed_output_items = 0
try:
for evt in iter_sse_event_payloads(upstream):
if callable(on_event):
Expand All @@ -182,13 +185,34 @@ def aggregate_response_from_sse(
if isinstance(response, dict):
response_obj = response
kind = evt.get("type")
if kind == "response.output_item.done":
item = evt.get("item")
if isinstance(item, dict):
output_index = evt.get("output_index")
if not isinstance(output_index, int):
# Indexed items are the protocol norm. Keep malformed or
# older unindexed events deterministically after them,
# preserving their arrival order.
output_index = 1_000_000 + unindexed_output_items
unindexed_output_items += 1
completed_output_items[output_index] = copy.deepcopy(item)
if kind == "response.failed":
if isinstance(response, dict) and isinstance(response.get("error"), dict):
error_obj = {"error": response.get("error")}
else:
error_obj = {"error": {"message": "response.failed"}}
break
if kind == "response.completed":
if (
isinstance(response_obj, dict)
and completed_output_items
and not response_obj.get("output")
):
response_obj = dict(response_obj)
response_obj["output"] = [
completed_output_items[index]
for index in sorted(completed_output_items)
]
break
finally:
upstream.close()
Expand Down
102 changes: 102 additions & 0 deletions tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,108 @@ def test_responses_route_returns_completed_response_object(self, mock_start) ->
self.assertEqual(outbound_payload["reasoning"]["effort"], "medium")
self.assertIsInstance(outbound_payload["prompt_cache_key"], str)

@patch("chatmock.routes_openai.start_upstream_raw_request")
def test_responses_route_reconstructs_non_stream_output_from_item_events(self, mock_start) -> None:
output = [
{
"type": "reasoning",
"id": "reasoning_1",
"summary": [{"type": "summary_text", "text": "Need the tool."}],
"encrypted_content": "encrypted",
},
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "get_time",
"arguments": '{"city":"Paris"}',
"status": "completed",
},
{
"type": "message",
"role": "assistant",
"id": "msg_1",
"status": "completed",
"content": [{"type": "output_text", "text": '{"city":"Paris"}'}],
},
]
events = [
{
"type": "response.created",
"response": {"id": "resp_items", "object": "response", "status": "in_progress"},
},
*[
{"type": "response.output_item.done", "output_index": index, "item": output[index]}
# Completion order is not output order; the protocol supplies
# output_index so non-stream aggregation can reconstruct it.
for index in (2, 0, 1)
],
{
"type": "response.completed",
"response": {
"id": "resp_items",
"object": "response",
"status": "completed",
"output": [],
},
},
]
mock_start.return_value = (
FakeUpstream(events, headers={"Content-Type": "text/event-stream"}),
None,
)

response = self.client.post(
"/v1/responses",
json={"model": "gpt-5.6-luna", "input": "Return structured output."},
)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()["output"], output)

@patch("chatmock.routes_openai.start_upstream_raw_request")
def test_responses_route_keeps_output_from_completed_response(self, mock_start) -> None:
authoritative = {
"type": "message",
"role": "assistant",
"id": "msg_final",
"content": [{"type": "output_text", "text": "final"}],
}
mock_start.return_value = (
FakeUpstream(
[
{
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": "msg_event",
"content": [{"type": "output_text", "text": "event"}],
},
},
{
"type": "response.completed",
"response": {
"id": "resp_final",
"object": "response",
"status": "completed",
"output": [authoritative],
},
},
],
headers={"Content-Type": "text/event-stream"},
),
None,
)

response = self.client.post(
"/v1/responses",
json={"model": "gpt-5.6-luna", "input": "hello"},
)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()["output"], [authoritative])

@patch("chatmock.routes_openai.start_upstream_raw_request")
def test_responses_route_honors_debug_model_override(self, mock_start) -> None:
app = create_app(debug_model="gpt-5.4", model_sync=False)
Expand Down