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

359 lines
12 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
import json
from collections.abc import AsyncIterator
from typing import Any
import settings
from db.session import SessionLocal
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from loguru import logger
from services.openai_realtime.auth import (
RealtimeAuthError,
RealtimeCredential,
authenticate_bearer,
bearer_from_authorization,
)
from services.openai_responses.protocol import (
ResponseRequest,
ResponsesAPIError,
error_payload,
new_id,
parse_request,
response_payload,
)
from services.openai_responses.runner import ResponseRunError, ResponsesTextRunner
from services.openai_responses.state import (
PreparedResponse,
finish_response,
get_response,
prepare_response,
)
router = APIRouter(prefix="/v1/responses", tags=["openai-responses"])
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:
return JSONResponse(status_code=error.status_code, content=error_payload(error))
def _sse(event: dict[str, Any]) -> bytes:
return (
f"event: {event['type']}\n"
f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
).encode("utf-8")
def _text_item(item_id: str, text: str) -> dict[str, Any]:
return {
"id": item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": text,
"annotations": [],
}
],
}
def _output_items(
*,
text: str,
text_item_id: str,
function_calls: list[dict[str, Any]],
) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
if text:
items.append(_text_item(text_item_id, text))
items.extend(function_calls)
return items
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",
output_items=_output_items(
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,
)
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:
await finish_response(
prepared.row.id,
status="failed",
output_items=[],
error={"code": "response_failed", "message": str(exc)},
)
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()
text_started = False
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)
yield _sse({"type": "response.created", "response": created})
yield _sse({"type": "response.in_progress", "response": created})
while True:
event = await queue.get()
if event is None:
break
if event["kind"] == "delta":
if not text_started:
text_started = True
pending_item = _text_item(text_item_id, "")
pending_item["status"] = "in_progress"
yield _sse(
{
"type": "response.output_item.added",
"output_index": 0,
"item": pending_item,
}
)
yield _sse(
{
"type": "response.content_part.added",
"item_id": text_item_id,
"output_index": 0,
"content_index": 0,
"part": {
"type": "output_text",
"text": "",
"annotations": [],
},
}
)
yield _sse(
{
"type": "response.output_text.delta",
"item_id": text_item_id,
"output_index": 0,
"content_index": 0,
"delta": event["delta"],
}
)
continue
if event["kind"] == "failed":
error: ResponsesAPIError = event["error"]
yield _sse({"type": "error", **error_payload(error)})
failed = await get_response(
prepared.row.id, api_key_id=prepared.row.api_key_id
)
yield _sse(
{"type": "response.failed", "response": response_payload(failed)}
)
continue
row = event["row"]
payload = response_payload(row)
output_index = 0
for item in row.output_items or []:
if item.get("type") == "message":
text = str((item.get("content") or [{}])[0].get("text") or "")
if not text_started:
yield _sse(
{
"type": "response.output_item.added",
"output_index": output_index,
"item": {
**item,
"status": "in_progress",
"content": [],
},
}
)
yield _sse(
{
"type": "response.content_part.added",
"item_id": item["id"],
"output_index": output_index,
"content_index": 0,
"part": {
"type": "output_text",
"text": "",
"annotations": [],
},
}
)
yield _sse(
{
"type": "response.output_text.done",
"item_id": item["id"],
"output_index": output_index,
"content_index": 0,
"text": text,
}
)
yield _sse(
{
"type": "response.content_part.done",
"item_id": item["id"],
"output_index": output_index,
"content_index": 0,
"part": item["content"][0],
}
)
yield _sse(
{
"type": "response.output_item.done",
"output_index": output_index,
"item": item,
}
)
else:
yield _sse(
{
"type": "response.output_item.added",
"output_index": output_index,
"item": item,
}
)
yield _sse(
{
"type": "response.output_item.done",
"output_index": output_index,
"item": item,
}
)
output_index += 1
yield _sse({"type": "response.completed", "response": payload})
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@router.post("")
async def create_response(request: Request):
try:
credential = await _credential(request)
body = await request.json()
parsed: ResponseRequest = parse_request(body)
prepared = await prepare_response(parsed, api_key_id=credential.api_key_id)
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 (json.JSONDecodeError, UnicodeDecodeError):
return _json_error(ResponsesAPIError("Request body must be valid JSON"))
except ResponsesAPIError as exc:
return _json_error(exc)
except ValueError as exc:
return _json_error(ResponsesAPIError(str(exc)))
@router.get("/{response_id}")
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)