Skip to content

Commit 25a5e88

Browse files
committed
feat(responses): add WebSocket transport and fix OpenResponses conformance gaps
Bring the Responses API up to full OpenResponses conformance (17/17, previously 2/17 in the conformance workflow). Add a WebSocket transport at /v1/responses. Each text frame is a response.create event that is streamed back as individual JSON event frames, terminating on response.completed/failed/incomplete. The transport keeps a connection-local cache of store=false response output so a follow-up previous_response_id on the same connection can continue a chain that was never persisted; a failed continuation evicts the entry it built on, and a fresh connection starts empty. Unknown or missing previous responses return a previous_response_not_found error envelope. This requires the websockets library, which is added as a dependency so uvicorn can negotiate the upgrade. Fix two response schema gaps: the response now always carries safety_identifier (string or null) and text.verbosity defaults to "medium" instead of serializing as null, which the OpenResponses optional-enum schema rejects. Fix the compaction endpoint to normalize bare-string user message content into a content-part list so compacted output items match the response message schema. Add replay recordings for the assistant-phase, compaction, and WebSocket turns so the conformance workflow passes without live inference. Signed-off-by: Charlie Doern <cdoern@redhat.com>
1 parent e140854 commit 25a5e88

22 files changed

Lines changed: 2182 additions & 4 deletions

client-sdks/stainless/openapi.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7382,6 +7382,10 @@ components:
73827382
store:
73837383
type: boolean
73847384
title: Store
7385+
safety_identifier:
7386+
anyOf:
7387+
- type: string
7388+
- type: 'null'
73857389
input:
73867390
items:
73877391
$ref: '#/components/schemas/OpenAIResponseMessageOutputUnion'
@@ -7477,6 +7481,7 @@ components:
74777481
- medium
74787482
- high
74797483
- type: 'null'
7484+
default: medium
74807485
type: object
74817486
title: OpenAIResponseText
74827487
description: Text response configuration for OpenAI responses.
@@ -7795,6 +7800,10 @@ components:
77957800
store:
77967801
type: boolean
77977802
title: Store
7803+
safety_identifier:
7804+
anyOf:
7805+
- type: string
7806+
- type: 'null'
77987807
type: object
77997808
required:
78007809
- created_at

docs/static/deprecated-ogx-spec.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3350,6 +3350,10 @@ components:
33503350
store:
33513351
type: boolean
33523352
title: Store
3353+
safety_identifier:
3354+
anyOf:
3355+
- type: string
3356+
- type: 'null'
33533357
input:
33543358
items:
33553359
$ref: '#/components/schemas/OpenAIResponseMessageOutputUnion'
@@ -3445,6 +3449,7 @@ components:
34453449
- medium
34463450
- high
34473451
- type: 'null'
3452+
default: medium
34483453
type: object
34493454
title: OpenAIResponseText
34503455
description: Text response configuration for OpenAI responses.
@@ -3763,6 +3768,10 @@ components:
37633768
store:
37643769
type: boolean
37653770
title: Store
3771+
safety_identifier:
3772+
anyOf:
3773+
- type: string
3774+
- type: 'null'
37663775
type: object
37673776
required:
37683777
- created_at

docs/static/experimental-ogx-spec.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3795,6 +3795,10 @@ components:
37953795
store:
37963796
type: boolean
37973797
title: Store
3798+
safety_identifier:
3799+
anyOf:
3800+
- type: string
3801+
- type: 'null'
37983802
input:
37993803
items:
38003804
$ref: '#/components/schemas/OpenAIResponseMessageOutputUnion'
@@ -3890,6 +3894,7 @@ components:
38903894
- medium
38913895
- high
38923896
- type: 'null'
3897+
default: medium
38933898
type: object
38943899
title: OpenAIResponseText
38953900
description: Text response configuration for OpenAI responses.
@@ -4208,6 +4213,10 @@ components:
42084213
store:
42094214
type: boolean
42104215
title: Store
4216+
safety_identifier:
4217+
anyOf:
4218+
- type: string
4219+
- type: 'null'
42114220
type: object
42124221
required:
42134222
- created_at

docs/static/ogx-spec.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6935,6 +6935,10 @@ components:
69356935
store:
69366936
type: boolean
69376937
title: Store
6938+
safety_identifier:
6939+
anyOf:
6940+
- type: string
6941+
- type: 'null'
69386942
input:
69396943
items:
69406944
$ref: '#/components/schemas/OpenAIResponseMessageOutputUnion'
@@ -7030,6 +7034,7 @@ components:
70307034
- medium
70317035
- high
70327036
- type: 'null'
7037+
default: medium
70337038
type: object
70347039
title: OpenAIResponseText
70357040
description: Text response configuration for OpenAI responses.
@@ -7348,6 +7353,10 @@ components:
73487353
store:
73497354
type: boolean
73507355
title: Store
7356+
safety_identifier:
7357+
anyOf:
7358+
- type: string
7359+
- type: 'null'
73517360
type: object
73527361
required:
73537362
- created_at

docs/static/stainless-ogx-spec.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7382,6 +7382,10 @@ components:
73827382
store:
73837383
type: boolean
73847384
title: Store
7385+
safety_identifier:
7386+
anyOf:
7387+
- type: string
7388+
- type: 'null'
73857389
input:
73867390
items:
73877391
$ref: '#/components/schemas/OpenAIResponseMessageOutputUnion'
@@ -7477,6 +7481,7 @@ components:
74777481
- medium
74787482
- high
74797483
- type: 'null'
7484+
default: medium
74807485
type: object
74817486
title: OpenAIResponseText
74827487
description: Text response configuration for OpenAI responses.
@@ -7795,6 +7800,10 @@ components:
77957800
store:
77967801
type: boolean
77977802
title: Store
7803+
safety_identifier:
7804+
anyOf:
7805+
- type: string
7806+
- type: 'null'
77987807
type: object
77997808
required:
78007809
- created_at

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ dependencies = [
6565
"termcolor",
6666
"tiktoken",
6767
"uvicorn>=0.34.0", # server
68+
"websockets>=14.0", # server - WebSocket transport for the Responses API
6869
"opentelemetry-sdk>=1.42.1", # server
6970
"opentelemetry-exporter-otlp-proto-http>=1.30.0", # server
7071
"opentelemetry-distro>=0.60b1", # optional CLI instrumentation; only pre-releases on PyPI (latest 0.60b1)

src/ogx/providers/inline/responses/builtin/responses/openai_responses.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1307,13 +1307,19 @@ async def compact_openai_response(
13071307
output_items: list[OpenAIResponseInput] = []
13081308
for item in all_input:
13091309
if isinstance(item, OpenAIResponseMessage) and item.role == "user":
1310+
# Normalize bare-string content to a content-part list so the
1311+
# compacted output message matches the response message schema,
1312+
# which requires content to be an array of parts.
1313+
content = item.content
1314+
if isinstance(content, str):
1315+
content = [OpenAIResponseInputMessageContentText(text=content)]
13101316
output_items.append(
13111317
OpenAIResponseMessage(
13121318
id=f"msg_{uuid.uuid4().hex[:24]}",
13131319
type="message",
13141320
status="completed",
13151321
role="user",
1316-
content=item.content,
1322+
content=content,
13171323
)
13181324
)
13191325

src/ogx_api/openai_responses.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,9 @@ class OpenAIResponseText(BaseModel):
480480
"""
481481

482482
format: OpenAIResponseTextFormat | None = None
483-
verbosity: Literal["low", "medium", "high"] | None = None
483+
# Defaults to "medium" to match OpenAI: the OpenResponses schema types verbosity as an
484+
# optional enum that rejects an explicit null, so the response must not serialize it as null.
485+
verbosity: Literal["low", "medium", "high"] | None = "medium"
484486

485487

486488
@json_schema_type
@@ -848,6 +850,7 @@ class OpenAIResponseObject(BaseModel):
848850
:param max_output_tokens: (Optional) An upper bound for the number of tokens that can be generated for a response, including visible output tokens.
849851
:param service_tier: (Optional) The service tier to use for this response.
850852
:param metadata: (Optional) Dictionary of metadata key-value pairs
853+
:param safety_identifier: (Optional) Stable identifier used to associate the request with an end user for safety monitoring
851854
"""
852855

853856
background: bool | None = Field(default=None, json_schema_extra=remove_null_from_anyof)
@@ -884,6 +887,7 @@ class OpenAIResponseObject(BaseModel):
884887
metadata: dict[str, str] | None = None
885888
presence_penalty: float | None = Field(default=None, json_schema_extra=remove_null_from_anyof)
886889
store: bool
890+
safety_identifier: str | None = None
887891

888892

889893
@json_schema_type

src/ogx_api/responses/fastapi_routes.py

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
from collections.abc import AsyncIterator
1818
from typing import Annotated, Any
1919

20-
from fastapi import APIRouter, Body, Depends, Path, Query, Request, Response
20+
from fastapi import APIRouter, Body, Depends, Path, Query, Request, Response, WebSocket, WebSocketDisconnect
2121
from fastapi.responses import StreamingResponse
22-
from pydantic import BaseModel
22+
from pydantic import BaseModel, ValidationError
2323

2424
from ogx_api.common.responses import Order
2525
from ogx_api.openai_responses import (
@@ -214,6 +214,134 @@ async def wrapper() -> AsyncIterator[str]:
214214
return wrapper()
215215

216216

217+
# Fields that the WebSocket response.create event forbids (they only apply to
218+
# the HTTP streaming transport) and the discriminator we strip before building
219+
# the request.
220+
_WS_STRIPPED_FIELDS = ("type", "stream", "stream_options", "background")
221+
222+
223+
def _ws_normalize_input(input_value: Any) -> list[Any]:
224+
"""Normalize a response.create `input` to a list of items for context reuse."""
225+
if input_value is None:
226+
return []
227+
if isinstance(input_value, str):
228+
return [{"type": "message", "role": "user", "content": input_value}]
229+
if isinstance(input_value, list):
230+
return list(input_value)
231+
return [input_value]
232+
233+
234+
async def _send_ws_error(
235+
websocket: WebSocket,
236+
status: int,
237+
code: str,
238+
message: str,
239+
param: str | None = None,
240+
) -> None:
241+
"""Send a WebSocket error envelope (matches the OpenResponses error event)."""
242+
await websocket.send_text(
243+
json.dumps(
244+
{
245+
"type": "error",
246+
"status": status,
247+
"error": {"code": code, "message": message, "param": param},
248+
}
249+
)
250+
)
251+
252+
253+
async def _handle_ws_responses_turn(
254+
websocket: WebSocket,
255+
impl: Responses,
256+
raw: str,
257+
session_cache: dict[str, tuple[list[Any], list[Any]]],
258+
) -> None:
259+
"""Process a single `response.create` event received over the WebSocket.
260+
261+
Streams each response event back as an individual JSON text frame. For
262+
`store=false` turns, the response output is cached connection-locally so a
263+
follow-up `previous_response_id` on the same connection can continue a chain
264+
that was never persisted server-side.
265+
"""
266+
try:
267+
payload = json.loads(raw)
268+
except (json.JSONDecodeError, TypeError):
269+
await _send_ws_error(websocket, 400, "invalid_json", "Failed to parse WebSocket message as JSON.")
270+
return
271+
if not isinstance(payload, dict):
272+
await _send_ws_error(
273+
websocket, 400, "invalid_request", "Failed to read response.create event; expected a JSON object."
274+
)
275+
return
276+
277+
for field in _WS_STRIPPED_FIELDS:
278+
payload.pop(field, None)
279+
280+
store = payload.get("store", True)
281+
previous_response_id = payload.get("previous_response_id")
282+
building_on: str | None = None
283+
284+
# store=false chains are not persisted, so continuation is served from the
285+
# connection-local cache rather than the responses store.
286+
if previous_response_id is not None and store is False:
287+
cached = session_cache.get(previous_response_id)
288+
if cached is None:
289+
await _send_ws_error(
290+
websocket,
291+
404,
292+
"previous_response_not_found",
293+
f"Previous response '{previous_response_id}' was not found.",
294+
param="previous_response_id",
295+
)
296+
return
297+
prev_input, prev_output = cached
298+
payload["input"] = [*prev_input, *prev_output, *_ws_normalize_input(payload.get("input"))]
299+
payload.pop("previous_response_id", None)
300+
building_on = previous_response_id
301+
302+
sent_input = _ws_normalize_input(payload.get("input"))
303+
304+
try:
305+
request = CreateResponseRequest(**{**payload, "stream": True})
306+
except ValidationError as exc:
307+
await _send_ws_error(websocket, 400, "invalid_request", str(exc))
308+
return
309+
310+
final_response: OpenAIResponseObject | None = None
311+
failed = False
312+
try:
313+
result = await impl.create_openai_response(request)
314+
if not isinstance(result, AsyncIterator):
315+
await _send_ws_error(websocket, 500, "server_error", "Expected a streaming response over WebSocket.")
316+
return
317+
async for event in result:
318+
await websocket.send_text(event.model_dump_json())
319+
event_type = getattr(event, "type", None)
320+
if event_type in ("response.completed", "response.incomplete"):
321+
final_response = getattr(event, "response", None)
322+
elif event_type == "response.failed":
323+
final_response = getattr(event, "response", None)
324+
failed = True
325+
except Exception as exc:
326+
logger.exception("WebSocket responses turn failed")
327+
failed = True
328+
http_exc = try_translate_to_http_exception(exc)
329+
status = http_exc.status_code if http_exc else 500
330+
detail = http_exc.detail if http_exc else "Internal server error: An unexpected error occurred."
331+
await _send_ws_error(websocket, status, "server_error", detail)
332+
333+
if failed:
334+
# A failed continuation evicts the response it built on, so subsequent
335+
# turns referencing it report previous_response_not_found.
336+
if building_on is not None:
337+
session_cache.pop(building_on, None)
338+
elif final_response is not None and store is False:
339+
session_cache[final_response.id] = (
340+
sent_input,
341+
[item.model_dump() for item in final_response.output],
342+
)
343+
344+
217345
def create_router(impl: Responses) -> APIRouter:
218346
"""Create a FastAPI router for the Responses API.
219347
@@ -230,6 +358,20 @@ def create_router(impl: Responses) -> APIRouter:
230358
route_class=FormURLEncodedRoute,
231359
)
232360

361+
@router.websocket("/responses")
362+
async def create_openai_response_ws(websocket: WebSocket) -> None:
363+
await websocket.accept()
364+
# Connection-local cache of store=false response output, keyed by response
365+
# id. Lets a follow-up previous_response_id on the same socket continue a
366+
# chain that was never persisted. A fresh connection starts empty.
367+
session_cache: dict[str, tuple[list[Any], list[Any]]] = {}
368+
try:
369+
while True:
370+
raw = await websocket.receive_text()
371+
await _handle_ws_responses_turn(websocket, impl, raw, session_cache)
372+
except WebSocketDisconnect:
373+
return
374+
233375
@router.post(
234376
"/responses/compact",
235377
response_model=OpenAICompactedResponse,

0 commit comments

Comments
 (0)