531 lines
18 KiB
Python
531 lines
18 KiB
Python
"""Public stateful OpenAI Responses-compatible text endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import AsyncIterator
|
|
from typing import Annotated, Any, Literal
|
|
|
|
import settings
|
|
from db.session import SessionLocal
|
|
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,
|
|
authenticate_bearer,
|
|
bearer_from_authorization,
|
|
)
|
|
from services.openai_responses.protocol import (
|
|
ResponseRequest,
|
|
ResponsesAPIError,
|
|
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"],
|
|
dependencies=[Depends(PUBLIC_API_BEARER)],
|
|
)
|
|
|
|
|
|
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 openai_error_response(
|
|
error.message,
|
|
code=error.code,
|
|
param=error.param,
|
|
status_code=error.status_code,
|
|
)
|
|
|
|
|
|
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=build_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,
|
|
usage=result.usage,
|
|
)
|
|
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:
|
|
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": "Response generation failed",
|
|
},
|
|
)
|
|
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()
|
|
started_text_items: set[str] = set()
|
|
sequence = SSEEventSequence()
|
|
|
|
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 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 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 sequence.encode(
|
|
"response.output_item.added",
|
|
output_index=0,
|
|
item=pending_item,
|
|
)
|
|
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 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 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 sequence.encode(
|
|
"response.failed",
|
|
response=response_payload(failed),
|
|
)
|
|
continue
|
|
|
|
row = event["row"]
|
|
payload = response_payload(row)
|
|
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)
|
|
|
|
|
|
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_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(
|
|
_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)))
|
|
|
|
|
|
@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)
|
|
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)
|
|
|
|
|
|
@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)
|