feat: harden public OpenAI API contracts
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user