feat: harden public OpenAI API contracts
Quality gate / backend (push) Canceled after 0s
Quality gate / frontend (push) Canceled after 0s

This commit is contained in:
Xin Wang
2026-09-07 14:25:48 +08:00
parent 8025ed8aa0
commit 2649d64b55
36 changed files with 16643 additions and 236 deletions
+92
View File
@@ -0,0 +1,92 @@
"""Model discovery for assistants exposed through the public API."""
from __future__ import annotations
from db.models import Assistant
from db.session import SessionLocal
from fastapi import APIRouter, Depends, Request
from services.openai_api import (
PUBLIC_API_BEARER,
PUBLIC_ERROR_RESPONSES,
REQUEST_ID_RESPONSE_HEADERS,
openai_error_response,
)
from services.openai_realtime.auth import (
RealtimeAuthError,
authenticate_bearer,
bearer_from_authorization,
)
from services.openai_responses.schemas import ModelList
from sqlalchemy import select
PUBLIC_ASSISTANT_TYPES = {"prompt", "workflow", "dify", "fastgpt"}
router = APIRouter(
prefix="/v1/models",
tags=["openai-models"],
dependencies=[Depends(PUBLIC_API_BEARER)],
)
async def _authenticate(request: Request) -> None:
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")
def _capabilities(assistant: Assistant) -> list[str]:
capabilities = ["realtime"]
if assistant.runtime_mode == "pipeline":
capabilities.insert(0, "responses")
return capabilities
@router.get(
"",
response_model=ModelList,
responses={
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
**PUBLIC_ERROR_RESPONSES,
},
summary="List assistants available through the public API",
)
async def list_models(request: Request):
try:
await _authenticate(request)
async with SessionLocal() as db:
assistants = (
await db.execute(
select(Assistant)
.where(
Assistant.type.in_(PUBLIC_ASSISTANT_TYPES),
Assistant.runtime_mode.in_(("pipeline", "realtime")),
)
.order_by(Assistant.created_at.asc(), Assistant.id.asc())
)
).scalars().all()
return {
"object": "list",
"data": [
{
"id": f"assistant:{assistant.id}",
"object": "model",
"created": int(assistant.created_at.timestamp()),
"owned_by": "interactive-media",
"name": assistant.name,
"capabilities": _capabilities(assistant),
"assistant_id": assistant.id,
"assistant_type": assistant.type,
"runtime_mode": assistant.runtime_mode,
}
for assistant in assistants
],
}
except RealtimeAuthError as exc:
return openai_error_response(
str(exc),
code="invalid_api_key",
status_code=401,
)
+90 -18
View File
@@ -8,9 +8,15 @@ from typing import Any
from uuid import uuid4
from db.session import SessionLocal
from fastapi import APIRouter, Request, WebSocket
from fastapi import APIRouter, Depends, Request, WebSocket
from fastapi.responses import JSONResponse, Response
from loguru import logger
from services.openai_api import (
PUBLIC_API_BEARER,
PUBLIC_ERROR_RESPONSES,
REQUEST_ID_RESPONSE_HEADERS,
openai_error_response,
)
from starlette.websockets import WebSocketState
from services.openai_realtime.auth import (
@@ -27,6 +33,10 @@ from services.openai_realtime.events import (
assistant_id_from_model,
)
from services.openai_realtime.session import OpenAIRealtimeSession
from services.openai_realtime.schemas import (
RealtimeClientSecretBody,
RealtimeClientSecretResponse,
)
from services.openai_realtime.webrtc import OpenAIRealtimeWebRTCConnection
from services.openai_realtime.websocket import build_openai_websocket_transport
from services.pipecat.pipeline import run_pipeline
@@ -44,17 +54,18 @@ router = APIRouter(prefix="/v1/realtime", tags=["openai-realtime"])
_webrtc_peers: dict[str, OpenAIRealtimeWebRTCConnection] = {}
def _api_error(message: str, *, code: str, status_code: int) -> JSONResponse:
return JSONResponse(
def _api_error(
message: str,
*,
code: str,
status_code: int,
param: str | None = None,
) -> JSONResponse:
return openai_error_response(
message,
code=code,
status_code=status_code,
content={
"error": {
"type": "invalid_request_error",
"code": code,
"message": message,
"param": None,
}
},
param=param,
)
@@ -124,14 +135,25 @@ async def _build_session(
return state
@router.post("/client_secrets")
async def create_realtime_client_secret(request: Request):
@router.post(
"/client_secrets",
response_model=RealtimeClientSecretResponse,
responses={
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
**PUBLIC_ERROR_RESPONSES,
},
dependencies=[Depends(PUBLIC_API_BEARER)],
summary="Create a short-lived Realtime client secret",
)
async def create_realtime_client_secret(
body: RealtimeClientSecretBody,
request: Request,
):
try:
credential = await _credential(request)
if credential.ephemeral:
raise RealtimeAuthError("A long-lived API key is required")
body = await request.json()
session = _session_payload(body)
session = _session_payload(body.model_dump(exclude_unset=True))
_reject_locked_creation_fields(session)
assistant_id = assistant_id_from_model(session.get("model"))
# Resolve now so invalid models/configurations fail before a browser gets a token.
@@ -155,13 +177,62 @@ async def create_realtime_client_secret(request: Request):
except RealtimeAuthError as exc:
return _api_error(str(exc), code="invalid_api_key", status_code=401)
except RealtimeEventError as exc:
return _api_error(str(exc), code=exc.code, status_code=400)
return _api_error(
str(exc), code=exc.code, status_code=400, param=exc.param
)
except ValueError as exc:
code = "invalid_model" if "助手不存在" in str(exc) else "invalid_session"
return _api_error(str(exc), code=code, status_code=400)
@router.post("/calls")
@router.post(
"/calls",
response_class=Response,
responses={
200: {
"description": "WebRTC SDP answer.",
"headers": {
**REQUEST_ID_RESPONSE_HEADERS,
"Location": {
"description": "Relative URL of the created Realtime call.",
"schema": {"type": "string"},
},
},
"content": {
"application/sdp": {
"schema": {"type": "string", "format": "sdp"},
"example": "v=0\\r\\no=- ...",
}
},
},
**PUBLIC_ERROR_RESPONSES,
},
dependencies=[Depends(PUBLIC_API_BEARER)],
openapi_extra={
"requestBody": {
"required": True,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["sdp", "session"],
"properties": {
"sdp": {"type": "string", "format": "sdp"},
"session": {
"type": "string",
"description": "JSON-encoded Realtime session.",
},
},
}
},
"application/sdp": {
"schema": {"type": "string", "format": "sdp"}
},
},
}
},
summary="Create a Realtime WebRTC call",
)
async def create_realtime_call(request: Request):
try:
credential = await _credential(request)
@@ -206,7 +277,8 @@ async def create_realtime_call(request: Request):
return _api_error(str(exc), code="invalid_api_key", status_code=401)
except (RealtimeEventError, json.JSONDecodeError, UnicodeDecodeError) as exc:
code = exc.code if isinstance(exc, RealtimeEventError) else "invalid_request_error"
return _api_error(str(exc), code=code, status_code=400)
param = exc.param if isinstance(exc, RealtimeEventError) else None
return _api_error(str(exc), code=code, status_code=400, param=param)
except ValueError as exc:
code = "invalid_model" if "助手不存在" in str(exc) else "invalid_session"
return _api_error(str(exc), code=code, status_code=400)
+330 -158
View File
@@ -3,15 +3,22 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from typing import Any
from typing import Annotated, Any, Literal
import settings
from db.session import SessionLocal
from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Header, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from loguru import logger
from services.openai_api import (
DeletedResource,
OpenAIErrorResponse,
PUBLIC_API_BEARER,
PUBLIC_ERROR_RESPONSES,
REQUEST_ID_RESPONSE_HEADERS,
openai_error_response,
)
from services.openai_realtime.auth import (
RealtimeAuthError,
RealtimeCredential,
@@ -21,21 +28,40 @@ from services.openai_realtime.auth import (
from services.openai_responses.protocol import (
ResponseRequest,
ResponsesAPIError,
error_payload,
new_id,
normalize_idempotency_key,
parse_request,
request_fingerprint,
response_payload,
)
from services.openai_responses.runner import ResponseRunError, ResponsesTextRunner
from services.openai_responses.schemas import (
ResponseCreateRequest,
ResponseInputItemList,
ResponseObject,
)
from services.openai_responses.state import (
PreparedResponse,
delete_response,
finish_response,
get_idempotent_response,
get_response,
prepare_response,
)
from services.openai_responses.wire import (
SSEEventSequence,
assistant_text_item,
build_output_items,
output_lifecycle_events,
stored_input_items,
)
router = APIRouter(prefix="/v1/responses", tags=["openai-responses"])
router = APIRouter(
prefix="/v1/responses",
tags=["openai-responses"],
dependencies=[Depends(PUBLIC_API_BEARER)],
)
async def _credential(request: Request) -> RealtimeCredential:
@@ -48,43 +74,12 @@ async def _credential(request: Request) -> RealtimeCredential:
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
return openai_error_response(
error.message,
code=error.code,
param=error.param,
status_code=error.status_code,
)
async def _run_prepared(
@@ -106,7 +101,7 @@ async def _run_prepared(
return await finish_response(
prepared.row.id,
status="completed",
output_items=_output_items(
output_items=build_output_items(
text=result.text,
text_item_id=text_item_id,
function_calls=result.function_calls,
@@ -114,6 +109,7 @@ async def _run_prepared(
context_messages=result.context_messages,
dynamic_variables=result.dynamic_variables,
runtime_state=result.runtime_state,
usage=result.usage,
)
except TimeoutError as exc:
await finish_response(
@@ -134,11 +130,17 @@ async def _run_prepared(
)
raise
except ResponseRunError as exc:
logger.warning(
f"Responses pipeline failed for {prepared.row.id}: {exc}"
)
await finish_response(
prepared.row.id,
status="failed",
output_items=[],
error={"code": "response_failed", "message": str(exc)},
error={
"code": "response_failed",
"message": "Response generation failed",
},
)
raise ResponsesAPIError(
"Response generation failed",
@@ -164,7 +166,8 @@ async def _stream_response(
text_item_id: str,
) -> AsyncIterator[bytes]:
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
text_started = False
started_text_items: set[str] = set()
sequence = SSEEventSequence()
async def on_delta(delta: str) -> None:
await queue.put({"kind": "delta", "delta": delta})
@@ -185,144 +188,203 @@ async def _stream_response(
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})
yield sequence.encode("response.created", response=created)
yield sequence.encode("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, "")
if text_item_id not in started_text_items:
started_text_items.add(text_item_id)
pending_item = assistant_text_item(text_item_id, "")
pending_item["status"] = "in_progress"
yield _sse(
{
"type": "response.output_item.added",
"output_index": 0,
"item": pending_item,
}
yield sequence.encode(
"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 sequence.encode(
"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"],
}
yield sequence.encode(
"response.output_text.delta",
item_id=text_item_id,
output_index=0,
content_index=0,
delta=event["delta"],
logprobs=[],
)
continue
if event["kind"] == "failed":
error: ResponsesAPIError = event["error"]
yield _sse({"type": "error", **error_payload(error)})
yield sequence.encode(
"error",
code=error.code,
message=error.message,
param=error.param,
)
failed = await get_response(
prepared.row.id, api_key_id=prepared.row.api_key_id
)
yield _sse(
{"type": "response.failed", "response": response_payload(failed)}
yield sequence.encode(
"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})
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)
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@router.post("")
async def create_response(request: Request):
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,
):
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)
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,
)
text_item_id = new_id("msg")
if parsed.stream:
return StreamingResponse(
@@ -336,15 +398,21 @@ async def create_response(request: Request):
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}")
@router.get(
"/{response_id}",
response_model=ResponseObject,
responses={
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
**PUBLIC_ERROR_RESPONSES,
},
summary="Retrieve a stored response",
)
async def retrieve_response(response_id: str, request: Request):
try:
credential = await _credential(request)
@@ -356,3 +424,107 @@ async def retrieve_response(response_id: str, request: Request):
)
except ResponsesAPIError as exc:
return _json_error(exc)
@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)