Files
ai-video-fullstack/backend/routes/openai_responses.py
T

531 lines
18 KiB
Python
Raw Normal View History

2026-09-07 00:15:32 +08:00
"""Public stateful OpenAI Responses-compatible text endpoints."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
2026-09-07 14:25:48 +08:00
from typing import Annotated, Any, Literal
2026-09-07 00:15:32 +08:00
import settings
from db.session import SessionLocal
2026-09-07 14:25:48 +08:00
from fastapi import APIRouter, Depends, Header, Query, Request
2026-09-07 00:15:32 +08:00
from fastapi.responses import JSONResponse, StreamingResponse
from loguru import logger
2026-09-07 14:25:48 +08:00
from services.openai_api import (
DeletedResource,
OpenAIErrorResponse,
PUBLIC_API_BEARER,
PUBLIC_ERROR_RESPONSES,
REQUEST_ID_RESPONSE_HEADERS,
openai_error_response,
)
2026-09-07 00:15:32 +08:00
from services.openai_realtime.auth import (
RealtimeAuthError,
RealtimeCredential,
authenticate_bearer,
bearer_from_authorization,
)
from services.openai_responses.protocol import (
ResponseRequest,
ResponsesAPIError,
new_id,
2026-09-07 14:25:48 +08:00
normalize_idempotency_key,
2026-09-07 00:15:32 +08:00
parse_request,
2026-09-07 14:25:48 +08:00
request_fingerprint,
2026-09-07 00:15:32 +08:00
response_payload,
)
from services.openai_responses.runner import ResponseRunError, ResponsesTextRunner
2026-09-07 14:25:48 +08:00
from services.openai_responses.schemas import (
ResponseCreateRequest,
ResponseInputItemList,
ResponseObject,
)
2026-09-07 00:15:32 +08:00
from services.openai_responses.state import (
PreparedResponse,
2026-09-07 14:25:48 +08:00
delete_response,
2026-09-07 00:15:32 +08:00
finish_response,
2026-09-07 14:25:48 +08:00
get_idempotent_response,
2026-09-07 00:15:32 +08:00
get_response,
prepare_response,
)
2026-09-07 14:25:48 +08:00
from services.openai_responses.wire import (
SSEEventSequence,
assistant_text_item,
build_output_items,
output_lifecycle_events,
stored_input_items,
)
2026-09-07 00:15:32 +08:00
2026-09-07 14:25:48 +08:00
router = APIRouter(
prefix="/v1/responses",
tags=["openai-responses"],
dependencies=[Depends(PUBLIC_API_BEARER)],
)
2026-09-07 00:15:32 +08:00
async def _credential(request: Request) -> RealtimeCredential:
token = bearer_from_authorization(request.headers.get("authorization"))
async with SessionLocal() as db:
credential = await authenticate_bearer(db, token)
if credential.ephemeral:
raise RealtimeAuthError("A long-lived API key is required")
return credential
def _json_error(error: ResponsesAPIError) -> JSONResponse:
2026-09-07 14:25:48 +08:00
return openai_error_response(
error.message,
code=error.code,
param=error.param,
status_code=error.status_code,
)
2026-09-07 00:15:32 +08:00
async def _run_prepared(
prepared: PreparedResponse,
*,
on_delta=None,
text_item_id: str,
):
try:
async with asyncio.timeout(settings.RESPONSES_REQUEST_TIMEOUT_SECONDS):
result = await ResponsesTextRunner().run(
prepared.config,
history_messages=prepared.history_messages,
current_messages=prepared.current_messages,
runtime_state=prepared.runtime_state,
client_tool_results=prepared.client_tool_results,
on_delta=on_delta,
)
return await finish_response(
prepared.row.id,
status="completed",
2026-09-07 14:25:48 +08:00
output_items=build_output_items(
2026-09-07 00:15:32 +08:00
text=result.text,
text_item_id=text_item_id,
function_calls=result.function_calls,
),
context_messages=result.context_messages,
dynamic_variables=result.dynamic_variables,
runtime_state=result.runtime_state,
2026-09-07 14:25:48 +08:00
usage=result.usage,
2026-09-07 00:15:32 +08:00
)
except TimeoutError as exc:
await finish_response(
prepared.row.id,
status="failed",
output_items=[],
error={"code": "response_timeout", "message": "Response timed out"},
)
raise ResponsesAPIError(
"Response timed out", code="response_timeout", status_code=504
) from exc
except asyncio.CancelledError:
await finish_response(
prepared.row.id,
status="cancelled",
output_items=[],
error={"code": "client_disconnected", "message": "Client disconnected"},
)
raise
except ResponseRunError as exc:
2026-09-07 14:25:48 +08:00
logger.warning(
f"Responses pipeline failed for {prepared.row.id}: {exc}"
)
2026-09-07 00:15:32 +08:00
await finish_response(
prepared.row.id,
status="failed",
output_items=[],
2026-09-07 14:25:48 +08:00
error={
"code": "response_failed",
"message": "Response generation failed",
},
2026-09-07 00:15:32 +08:00
)
raise ResponsesAPIError(
"Response generation failed",
code="response_failed",
status_code=500,
) from exc
except Exception as exc: # noqa: BLE001
logger.exception(f"Responses generation failed: {exc}")
await finish_response(
prepared.row.id,
status="failed",
output_items=[],
error={"code": "internal_error", "message": "Response generation failed"},
)
raise ResponsesAPIError(
"Response generation failed", code="internal_error", status_code=500
) from exc
async def _stream_response(
prepared: PreparedResponse,
*,
text_item_id: str,
) -> AsyncIterator[bytes]:
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
2026-09-07 14:25:48 +08:00
started_text_items: set[str] = set()
sequence = SSEEventSequence()
2026-09-07 00:15:32 +08:00
async def on_delta(delta: str) -> None:
await queue.put({"kind": "delta", "delta": delta})
async def generate() -> None:
try:
row = await _run_prepared(
prepared,
on_delta=on_delta,
text_item_id=text_item_id,
)
await queue.put({"kind": "completed", "row": row})
except ResponsesAPIError as exc:
await queue.put({"kind": "failed", "error": exc})
finally:
await queue.put(None)
task = asyncio.create_task(generate(), name=f"stream-{prepared.row.id}")
try:
created = response_payload(prepared.row)
2026-09-07 14:25:48 +08:00
yield sequence.encode("response.created", response=created)
yield sequence.encode("response.in_progress", response=created)
2026-09-07 00:15:32 +08:00
while True:
event = await queue.get()
if event is None:
break
if event["kind"] == "delta":
2026-09-07 14:25:48 +08:00
if text_item_id not in started_text_items:
started_text_items.add(text_item_id)
pending_item = assistant_text_item(text_item_id, "")
2026-09-07 00:15:32 +08:00
pending_item["status"] = "in_progress"
2026-09-07 14:25:48 +08:00
yield sequence.encode(
"response.output_item.added",
output_index=0,
item=pending_item,
2026-09-07 00:15:32 +08:00
)
2026-09-07 14:25:48 +08:00
yield sequence.encode(
"response.content_part.added",
item_id=text_item_id,
output_index=0,
content_index=0,
part={
"type": "output_text",
"text": "",
"annotations": [],
},
2026-09-07 00:15:32 +08:00
)
2026-09-07 14:25:48 +08:00
yield sequence.encode(
"response.output_text.delta",
item_id=text_item_id,
output_index=0,
content_index=0,
delta=event["delta"],
logprobs=[],
2026-09-07 00:15:32 +08:00
)
continue
if event["kind"] == "failed":
error: ResponsesAPIError = event["error"]
2026-09-07 14:25:48 +08:00
yield sequence.encode(
"error",
code=error.code,
message=error.message,
param=error.param,
)
2026-09-07 00:15:32 +08:00
failed = await get_response(
prepared.row.id, api_key_id=prepared.row.api_key_id
)
2026-09-07 14:25:48 +08:00
yield sequence.encode(
"response.failed",
response=response_payload(failed),
2026-09-07 00:15:32 +08:00
)
continue
row = event["row"]
payload = response_payload(row)
2026-09-07 14:25:48 +08:00
for output_event in output_lifecycle_events(
row,
started_text_items=started_text_items,
replay_text=False,
):
event_type = output_event.pop("type")
yield sequence.encode(event_type, **output_event)
yield sequence.encode("response.completed", response=payload)
2026-09-07 00:15:32 +08:00
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
2026-09-07 14:25:48 +08:00
async def _stream_stored_response(row) -> AsyncIterator[bytes]:
"""Replay a persisted terminal response for an idempotent streaming call."""
sequence = SSEEventSequence()
final_payload = response_payload(row)
initial_payload = {
**final_payload,
"status": "in_progress",
"completed_at": None,
"error": None,
"output": [],
}
yield sequence.encode("response.created", response=initial_payload)
yield sequence.encode("response.in_progress", response=initial_payload)
if row.status == "completed":
for output_event in output_lifecycle_events(
row,
started_text_items=set(),
replay_text=True,
):
event_type = output_event.pop("type")
yield sequence.encode(event_type, **output_event)
yield sequence.encode("response.completed", response=final_payload)
return
error = row.error or {
"code": "response_cancelled",
"message": "Response was cancelled",
}
yield sequence.encode(
"error",
code=str(error.get("code") or "response_failed"),
message=str(error.get("message") or "Response generation failed"),
param=None,
)
yield sequence.encode("response.failed", response=final_payload)
def _idempotent_error(row) -> JSONResponse | None:
if row.status == "completed":
return None
error = row.error or {}
code = str(error.get("code") or "response_failed")
status_code = 504 if code == "response_timeout" else 500
if row.status == "cancelled":
code = str(error.get("code") or "response_cancelled")
status_code = 409
return openai_error_response(
str(error.get("message") or f"Response is {row.status}"),
code=code,
status_code=status_code,
headers={"X-Idempotent-Replayed": "true"},
)
@router.post(
"",
response_model=ResponseObject,
responses={
200: {
"description": "A completed Response or a stream of Responses events.",
"headers": {
**REQUEST_ID_RESPONSE_HEADERS,
"X-Idempotent-Replayed": {
"description": "Present and true when a stored result is replayed.",
"schema": {"type": "string", "enum": ["true"]},
},
},
"content": {
"text/event-stream": {
"schema": {"type": "string"},
"example": (
"event: response.created\\n"
'data: {"type":"response.created",...}\\n\\n'
),
}
},
},
**PUBLIC_ERROR_RESPONSES,
},
summary="Create a stateful model response",
)
async def create_response(
body: ResponseCreateRequest,
request: Request,
idempotency_key_header: Annotated[
str | None,
Header(
alias="Idempotency-Key",
description="Retry key scoped to the authenticated API key.",
),
] = None,
):
2026-09-07 00:15:32 +08:00
try:
credential = await _credential(request)
2026-09-07 14:25:48 +08:00
body_value = body.model_dump(exclude_unset=True)
parsed: ResponseRequest = parse_request(body_value)
idempotency_key = normalize_idempotency_key(idempotency_key_header)
fingerprint = request_fingerprint(body_value)
stored = await get_idempotent_response(
api_key_id=credential.api_key_id,
idempotency_key=idempotency_key,
request_hash=fingerprint,
)
if stored is not None:
if parsed.stream:
return StreamingResponse(
_stream_stored_response(stored),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"X-Idempotent-Replayed": "true",
},
)
replay_error = _idempotent_error(stored)
if replay_error is not None:
return replay_error
return JSONResponse(
content=response_payload(stored),
headers={"X-Idempotent-Replayed": "true"},
)
prepared = await prepare_response(
parsed,
api_key_id=credential.api_key_id,
idempotency_key=idempotency_key,
request_hash=fingerprint if idempotency_key else None,
)
2026-09-07 00:15:32 +08:00
text_item_id = new_id("msg")
if parsed.stream:
return StreamingResponse(
_stream_response(prepared, text_item_id=text_item_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
row = await _run_prepared(prepared, text_item_id=text_item_id)
return response_payload(row)
except RealtimeAuthError as exc:
return _json_error(
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
)
except ResponsesAPIError as exc:
return _json_error(exc)
except ValueError as exc:
return _json_error(ResponsesAPIError(str(exc)))
2026-09-07 14:25:48 +08:00
@router.get(
"/{response_id}",
response_model=ResponseObject,
responses={
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
**PUBLIC_ERROR_RESPONSES,
},
summary="Retrieve a stored response",
)
2026-09-07 00:15:32 +08:00
async def retrieve_response(response_id: str, request: Request):
try:
credential = await _credential(request)
row = await get_response(response_id, api_key_id=credential.api_key_id)
return response_payload(row)
except RealtimeAuthError as exc:
return _json_error(
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
)
except ResponsesAPIError as exc:
return _json_error(exc)
2026-09-07 14:25:48 +08:00
@router.get(
"/{response_id}/input_items",
response_model=ResponseInputItemList,
responses={
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
**PUBLIC_ERROR_RESPONSES,
},
summary="List input items for a stored response",
)
async def list_response_input_items(
response_id: str,
request: Request,
after: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
order: Annotated[Literal["asc", "desc"], Query()] = "desc",
include: Annotated[list[str] | None, Query()] = None,
):
try:
credential = await _credential(request)
if include:
raise ResponsesAPIError(
"include is not supported by this API subset",
code="unsupported_parameter",
param="include",
)
row = await get_response(response_id, api_key_id=credential.api_key_id)
items = stored_input_items(row)
ordered = items if order == "asc" else list(reversed(items))
start = 0
if after is not None:
try:
start = next(
index + 1
for index, item in enumerate(ordered)
if item["id"] == after
)
except StopIteration as exc:
raise ResponsesAPIError(
"after does not identify an input item in this response",
code="invalid_cursor",
param="after",
) from exc
data = ordered[start : start + limit]
return {
"object": "list",
"data": data,
"first_id": data[0]["id"] if data else None,
"last_id": data[-1]["id"] if data else None,
"has_more": start + len(data) < len(ordered),
}
except RealtimeAuthError as exc:
return _json_error(
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
)
except ResponsesAPIError as exc:
return _json_error(exc)
@router.delete(
"/{response_id}",
response_model=DeletedResource,
responses={
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
**PUBLIC_ERROR_RESPONSES,
},
summary="Delete a stored response",
)
async def remove_response(response_id: str, request: Request):
try:
credential = await _credential(request)
await delete_response(response_id, api_key_id=credential.api_key_id)
return {"id": response_id, "object": "response", "deleted": True}
except RealtimeAuthError as exc:
return _json_error(
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
)
except ResponsesAPIError as exc:
return _json_error(exc)
@router.post(
"/{response_id}/cancel",
response_model=OpenAIErrorResponse,
status_code=400,
responses=PUBLIC_ERROR_RESPONSES,
summary="Reject background cancellation in subset v1",
)
async def reject_response_cancellation(response_id: str, request: Request):
del response_id
try:
await _credential(request)
raise ResponsesAPIError(
"Response cancellation requires background mode, which is not supported",
code="unsupported_operation",
param="background",
)
except RealtimeAuthError as exc:
return _json_error(
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
)
except ResponsesAPIError as exc:
return _json_error(exc)