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
+83 -2
View File
@@ -24,6 +24,12 @@ from contextlib import asynccontextmanager
import settings
import uvicorn
from db.session import sync_default_tools, sync_interface_definitions
from loguru import logger
from services.openai_api import (
PUBLIC_API_NAME,
RequestIDMiddleware,
openai_error_response,
)
from services.knowledge import recover_interrupted_documents
from services.openai_responses.state import recover_interrupted_responses
from services.post_call.worker import recover_interrupted_analyses, run_analysis_worker
@@ -32,8 +38,14 @@ from services.test_runs.orchestrator import (
recover_interrupted_test_runs,
test_run_orchestrator,
)
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.exception_handlers import (
http_exception_handler,
request_validation_exception_handler,
)
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException
from routes import (
assistants,
@@ -45,6 +57,7 @@ from routes import (
mcp_servers,
model_registry,
node_types,
openai_models,
openai_realtime,
openai_responses,
realtime_api_keys,
@@ -85,7 +98,12 @@ async def lifespan(_app: FastAPI):
await voice_webrtc.shutdown_active_sessions()
app = FastAPI(title="AI Video Assistant 平台 - 后端", lifespan=lifespan)
app = FastAPI(
title="AI Video Assistant 平台 - 后端",
description=f"Public protocol: {PUBLIC_API_NAME}.",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
@@ -93,7 +111,69 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-ID", "X-Idempotent-Replayed", "Location"],
)
app.add_middleware(RequestIDMiddleware)
def _validation_param(error: dict) -> str | None:
location = list(error.get("loc") or [])
if location and location[0] in {"body", "query", "path", "header"}:
location.pop(0)
if not location or location == ["json"]:
return None
value = ""
for part in location:
if isinstance(part, int):
value += f"[{part}]"
else:
value += ("." if value else "") + str(part)
return value or None
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
):
if not request.url.path.startswith("/v1/"):
return await request_validation_exception_handler(request, exc)
first = (exc.errors() or [{}])[0]
return openai_error_response(
str(first.get("msg") or "Request does not match the API schema"),
code="invalid_request_error",
param=_validation_param(first),
status_code=422,
)
@app.exception_handler(StarletteHTTPException)
async def public_http_exception_handler(
request: Request,
exc: StarletteHTTPException,
):
if not request.url.path.startswith("/v1/"):
return await http_exception_handler(request, exc)
code = "not_found" if exc.status_code == 404 else "http_error"
return openai_error_response(
str(exc.detail),
code=code,
status_code=exc.status_code,
)
@app.exception_handler(Exception)
async def public_unhandled_exception_handler(request: Request, exc: Exception):
if not request.url.path.startswith("/v1/"):
raise exc
logger.exception(
f"Unhandled public API error: {type(exc).__name__}"
)
return openai_error_response(
"The server encountered an internal error",
code="internal_error",
status_code=500,
)
app.include_router(health.router)
app.include_router(auth.router)
@@ -104,6 +184,7 @@ app.include_router(knowledge_bases.router)
app.include_router(mcp_servers.router)
app.include_router(model_registry.router)
app.include_router(node_types.router)
app.include_router(openai_models.router)
app.include_router(openai_realtime.router)
app.include_router(openai_responses.router)
app.include_router(realtime_api_keys.router)
+11
View File
@@ -0,0 +1,11 @@
# Public API contract snapshots
These generated files define **OpenAI-compatible subset v1**:
- `openapi.json` describes public and admin HTTP routes.
- `openai-realtime-client-events.schema.json` validates accepted Realtime events.
- `openai-realtime-server-events.schema.json` validates the common server-event envelope, supported event names, and extension version.
From the repository root, run `make contract-snapshots` after an intentional contract change. The contract
test fails when generated output differs, so snapshot changes must be reviewed
alongside implementation and documentation changes.
@@ -0,0 +1,452 @@
{
"$defs": {
"CapabilitiesUpdateEvent": {
"additionalProperties": true,
"properties": {
"capabilities": {
"items": {
"type": "string"
},
"title": "Capabilities",
"type": "array"
},
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"type": {
"const": "x.interactive_media.capabilities.update",
"title": "Type",
"type": "string"
},
"version": {
"const": "1",
"title": "Version",
"type": "string"
}
},
"required": [
"type",
"version",
"capabilities"
],
"title": "CapabilitiesUpdateEvent",
"type": "object"
},
"ConversationItemCreateEvent": {
"additionalProperties": true,
"properties": {
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"item": {
"additionalProperties": true,
"title": "Item",
"type": "object"
},
"previous_item_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Previous Item Id"
},
"type": {
"const": "conversation.item.create",
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"item"
],
"title": "ConversationItemCreateEvent",
"type": "object"
},
"ConversationItemTruncateEvent": {
"additionalProperties": true,
"properties": {
"audio_end_ms": {
"minimum": 0,
"title": "Audio End Ms",
"type": "integer"
},
"content_index": {
"default": 0,
"minimum": 0,
"title": "Content Index",
"type": "integer"
},
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"item_id": {
"title": "Item Id",
"type": "string"
},
"type": {
"const": "conversation.item.truncate",
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"item_id",
"audio_end_ms"
],
"title": "ConversationItemTruncateEvent",
"type": "object"
},
"InputAudioBufferAppendEvent": {
"additionalProperties": true,
"properties": {
"audio": {
"title": "Audio",
"type": "string"
},
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"type": {
"const": "input_audio_buffer.append",
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"audio"
],
"title": "InputAudioBufferAppendEvent",
"type": "object"
},
"InputAudioBufferClearEvent": {
"additionalProperties": true,
"properties": {
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"type": {
"const": "input_audio_buffer.clear",
"title": "Type",
"type": "string"
}
},
"required": [
"type"
],
"title": "InputAudioBufferClearEvent",
"type": "object"
},
"InputAudioBufferCommitEvent": {
"additionalProperties": true,
"properties": {
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"type": {
"const": "input_audio_buffer.commit",
"title": "Type",
"type": "string"
}
},
"required": [
"type"
],
"title": "InputAudioBufferCommitEvent",
"type": "object"
},
"OutputAudioBufferClearEvent": {
"additionalProperties": true,
"properties": {
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"type": {
"const": "output_audio_buffer.clear",
"title": "Type",
"type": "string"
}
},
"required": [
"type"
],
"title": "OutputAudioBufferClearEvent",
"type": "object"
},
"ResponseCancelEvent": {
"additionalProperties": true,
"properties": {
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"response_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Response Id"
},
"type": {
"const": "response.cancel",
"title": "Type",
"type": "string"
}
},
"required": [
"type"
],
"title": "ResponseCancelEvent",
"type": "object"
},
"ResponseCreateEvent": {
"additionalProperties": true,
"properties": {
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"response": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"title": "Response"
},
"type": {
"const": "response.create",
"title": "Type",
"type": "string"
}
},
"required": [
"type"
],
"title": "ResponseCreateEvent",
"type": "object"
},
"SessionUpdateEvent": {
"additionalProperties": true,
"properties": {
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"session": {
"additionalProperties": true,
"title": "Session",
"type": "object"
},
"type": {
"const": "session.update",
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"session"
],
"title": "SessionUpdateEvent",
"type": "object"
},
"SessionVariablesUpdateEvent": {
"additionalProperties": true,
"properties": {
"event_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Event Id"
},
"type": {
"const": "x.interactive_media.session.variables.update",
"title": "Type",
"type": "string"
},
"variables": {
"additionalProperties": true,
"title": "Variables",
"type": "object"
},
"version": {
"const": "1",
"title": "Version",
"type": "string"
}
},
"required": [
"type",
"version",
"variables"
],
"title": "SessionVariablesUpdateEvent",
"type": "object"
}
},
"$id": "https://interactive-media.local/schemas/realtime-client-events-v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"discriminator": {
"mapping": {
"conversation.item.create": "#/$defs/ConversationItemCreateEvent",
"conversation.item.truncate": "#/$defs/ConversationItemTruncateEvent",
"input_audio_buffer.append": "#/$defs/InputAudioBufferAppendEvent",
"input_audio_buffer.clear": "#/$defs/InputAudioBufferClearEvent",
"input_audio_buffer.commit": "#/$defs/InputAudioBufferCommitEvent",
"output_audio_buffer.clear": "#/$defs/OutputAudioBufferClearEvent",
"response.cancel": "#/$defs/ResponseCancelEvent",
"response.create": "#/$defs/ResponseCreateEvent",
"session.update": "#/$defs/SessionUpdateEvent",
"x.interactive_media.capabilities.update": "#/$defs/CapabilitiesUpdateEvent",
"x.interactive_media.session.variables.update": "#/$defs/SessionVariablesUpdateEvent"
},
"propertyName": "type"
},
"oneOf": [
{
"$ref": "#/$defs/SessionUpdateEvent"
},
{
"$ref": "#/$defs/ConversationItemCreateEvent"
},
{
"$ref": "#/$defs/ConversationItemTruncateEvent"
},
{
"$ref": "#/$defs/InputAudioBufferAppendEvent"
},
{
"$ref": "#/$defs/InputAudioBufferCommitEvent"
},
{
"$ref": "#/$defs/InputAudioBufferClearEvent"
},
{
"$ref": "#/$defs/OutputAudioBufferClearEvent"
},
{
"$ref": "#/$defs/ResponseCreateEvent"
},
{
"$ref": "#/$defs/ResponseCancelEvent"
},
{
"$ref": "#/$defs/CapabilitiesUpdateEvent"
},
{
"$ref": "#/$defs/SessionVariablesUpdateEvent"
}
],
"title": "Interactive Media OpenAI Realtime client events v1"
}
@@ -0,0 +1,91 @@
{
"$id": "https://interactive-media.local/schemas/realtime-server-events-v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": true,
"allOf": [
{
"if": {
"properties": {
"type": {
"enum": [
"x.interactive_media.capabilities.updated",
"x.interactive_media.session.variables.updated",
"x.interactive_media.workflow.node_active",
"x.interactive_media.workflow.event",
"x.interactive_media.workflow.variables.updated",
"x.interactive_media.workflow.error",
"x.interactive_media.call.handoff_requested",
"x.interactive_media.call.ended"
]
}
},
"required": [
"type"
]
},
"then": {
"properties": {
"version": {
"const": "1",
"type": "string"
}
},
"required": [
"version"
]
}
}
],
"properties": {
"event_id": {
"title": "Event Id",
"type": "string"
},
"type": {
"enum": [
"session.created",
"session.updated",
"conversation.item.added",
"conversation.item.done",
"conversation.item.truncated",
"conversation.item.input_audio_transcription.completed",
"input_audio_buffer.committed",
"input_audio_buffer.cleared",
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
"output_audio_buffer.cleared",
"response.created",
"response.output_item.added",
"response.output_item.done",
"response.content_part.added",
"response.content_part.done",
"response.output_text.delta",
"response.output_text.done",
"response.output_audio.delta",
"response.output_audio.done",
"response.output_audio_transcript.delta",
"response.output_audio_transcript.done",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.done",
"error",
"x.interactive_media.capabilities.updated",
"x.interactive_media.session.variables.updated",
"x.interactive_media.workflow.node_active",
"x.interactive_media.workflow.event",
"x.interactive_media.workflow.variables.updated",
"x.interactive_media.workflow.error",
"x.interactive_media.call.handoff_requested",
"x.interactive_media.call.ended"
],
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"event_id"
],
"title": "Interactive Media OpenAI Realtime server events v1",
"type": "object"
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -428,6 +428,13 @@ class OpenAIResponse(Base):
"""Persisted state for one public Responses API generation."""
__tablename__ = "openai_responses"
__table_args__ = (
UniqueConstraint(
"api_key_id",
"idempotency_key",
name="uq_openai_response_idempotency",
),
)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
@@ -458,13 +465,21 @@ class OpenAIResponse(Base):
context_messages: Mapped[list] = mapped_column(JSONB, default=list)
runtime_state: Mapped[dict] = mapped_column(JSONB, default=dict)
error: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
usage: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
dynamic_variables: Mapped[dict] = mapped_column(JSONB, default=dict)
idempotency_key: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
request_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
@@ -0,0 +1,64 @@
"""add Responses idempotency, usage, and tombstones
Revision ID: 20260907_0025
Revises: 20260826_0024
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260907_0025"
down_revision: str | Sequence[str] | None = "20260826_0024"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"openai_responses",
sa.Column("usage", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
op.add_column(
"openai_responses",
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
)
op.add_column(
"openai_responses",
sa.Column("request_hash", sa.String(length=64), nullable=True),
)
op.add_column(
"openai_responses",
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_unique_constraint(
"uq_openai_response_idempotency",
"openai_responses",
["api_key_id", "idempotency_key"],
)
op.create_index(
"ix_openai_responses_deleted_at",
"openai_responses",
["deleted_at"],
)
def downgrade() -> None:
op.drop_index(
"ix_openai_responses_deleted_at",
table_name="openai_responses",
)
op.drop_constraint(
"uq_openai_response_idempotency",
"openai_responses",
type_="unique",
)
op.drop_column("openai_responses", "deleted_at")
op.drop_column("openai_responses", "request_hash")
op.drop_column("openai_responses", "idempotency_key")
op.drop_column("openai_responses", "usage")
+2
View File
@@ -13,6 +13,8 @@ dify-client-python==1.0.3
fastapi
httpx
jsonschema==4.26.0
openai==2.44.0
uvicorn[standard]
python-dotenv
pydantic
+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)
+51
View File
@@ -0,0 +1,51 @@
"""Generate deterministic public API contract snapshots.
Run from the repository root with:
make contract-snapshots
The files are reviewed and committed like source code. Tests fail when runtime
schemas drift from these snapshots.
"""
from __future__ import annotations
import json
from pathlib import Path
from app import app
from services.openai_realtime.schemas import (
realtime_client_event_json_schema,
realtime_server_event_json_schema,
)
CONTRACT_DIRECTORY = Path(__file__).resolve().parents[1] / "contracts"
def contract_documents() -> dict[str, dict]:
return {
"openapi.json": app.openapi(),
"openai-realtime-client-events.schema.json": (
realtime_client_event_json_schema()
),
"openai-realtime-server-events.schema.json": (
realtime_server_event_json_schema()
),
}
def main() -> None:
CONTRACT_DIRECTORY.mkdir(parents=True, exist_ok=True)
for name, document in contract_documents().items():
destination = CONTRACT_DIRECTORY / name
destination.write_text(
json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True)
+ "\n",
encoding="utf-8",
)
print(destination.relative_to(CONTRACT_DIRECTORY.parent))
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
"""Run the backend suite with all local-runtime prerequisites configured."""
from __future__ import annotations
import os
import unittest
def main() -> None:
# Must be set before test discovery imports Pipecat and NLTK.
os.environ.setdefault("NLTK_DISABLE_IMPORT_SECURITY", "1")
suite = unittest.defaultTestLoader.discover("tests")
result = unittest.TextTestRunner(verbosity=1).run(suite)
raise SystemExit(0 if result.wasSuccessful() else 1)
if __name__ == "__main__":
main()
+195
View File
@@ -0,0 +1,195 @@
"""Shared HTTP primitives for the public OpenAI-compatible API."""
from __future__ import annotations
import re
from typing import Any, Literal
from uuid import uuid4
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from pydantic import BaseModel, ConfigDict
from starlette.datastructures import Headers, MutableHeaders
from starlette.types import ASGIApp, Message, Receive, Scope, Send
PUBLIC_API_NAME = "OpenAI-compatible subset v1"
REQUEST_ID_HEADER = "X-Request-ID"
_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
PUBLIC_API_BEARER = HTTPBearer(
auto_error=False,
scheme_name="PublicAPIKey",
description="Long-lived sk-rt-... API key or an endpoint-approved client secret.",
)
REQUEST_ID_RESPONSE_HEADERS = {
REQUEST_ID_HEADER: {
"description": "Request identifier generated by the server or supplied by the caller.",
"schema": {"type": "string"},
}
}
class OpenAIErrorDetail(BaseModel):
"""Error shape consumed by official OpenAI SDKs."""
model_config = ConfigDict(extra="forbid")
type: str = "invalid_request_error"
code: str
message: str
param: str | None = None
class OpenAIErrorResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
error: OpenAIErrorDetail
class DeletedResource(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
object: Literal["response"] = "response"
deleted: Literal[True] = True
PUBLIC_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
400: {
"model": OpenAIErrorResponse,
"description": "The request is unsupported or semantically invalid.",
},
401: {
"model": OpenAIErrorResponse,
"description": "The API credential is missing, invalid, or expired.",
},
404: {
"model": OpenAIErrorResponse,
"description": "The resource does not exist or belongs to another API key.",
},
409: {
"model": OpenAIErrorResponse,
"description": "The requested state transition conflicts with current state.",
},
422: {
"model": OpenAIErrorResponse,
"description": "The JSON body or query parameters do not match the schema.",
},
429: {
"model": OpenAIErrorResponse,
"description": "A request or concurrency limit was exceeded.",
},
503: {
"model": OpenAIErrorResponse,
"description": "The service is temporarily unavailable.",
},
500: {
"model": OpenAIErrorResponse,
"description": "The response failed because of an internal error.",
},
504: {
"model": OpenAIErrorResponse,
"description": "The response exceeded its generation timeout.",
},
}
_ERROR_EXAMPLES = {
400: ("unsupported_parameter", "The requested parameter is not supported."),
401: ("invalid_api_key", "Invalid or expired API key."),
404: ("response_not_found", "Response not found."),
409: ("response_not_ready", "The requested state transition conflicts."),
422: ("invalid_request_error", "Request does not match the API schema."),
429: ("rate_limit_exceeded", "The request limit was exceeded."),
500: ("internal_error", "The response failed unexpectedly."),
503: ("service_unavailable", "The service is temporarily unavailable."),
504: ("response_timeout", "Response timed out."),
}
def error_payload(
message: str,
*,
code: str,
param: str | None = None,
error_type: str = "invalid_request_error",
) -> dict[str, Any]:
return OpenAIErrorResponse(
error=OpenAIErrorDetail(
type=error_type,
code=code,
message=message,
param=param,
)
).model_dump()
for _status, _response in PUBLIC_ERROR_RESPONSES.items():
_code, _message = _ERROR_EXAMPLES[int(_status)]
_response["content"] = {
"application/json": {
"example": error_payload(
_message,
code=_code,
)
}
}
_response["headers"] = REQUEST_ID_RESPONSE_HEADERS
def openai_error_response(
message: str,
*,
code: str,
status_code: int,
param: str | None = None,
error_type: str = "invalid_request_error",
headers: dict[str, str] | None = None,
) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content=error_payload(
message,
code=code,
param=param,
error_type=error_type,
),
headers=headers,
)
def normalize_request_id(value: str | None) -> str:
"""Reuse a safe caller ID or create one suitable for logs and support tickets."""
candidate = (value or "").strip()
if _VALID_REQUEST_ID.fullmatch(candidate):
return candidate
return f"req_{uuid4().hex}"
class RequestIDMiddleware:
"""Add a stable request ID without buffering SSE response bodies."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request_id = normalize_request_id(
Headers(scope=scope).get(REQUEST_ID_HEADER)
)
scope.setdefault("state", {})["request_id"] = request_id
async def send_with_request_id(message: Message) -> None:
if message["type"] == "http.response.start":
MutableHeaders(scope=message)[REQUEST_ID_HEADER] = request_id
await send(message)
await self.app(scope, receive, send_with_request_id)
+27 -7
View File
@@ -34,6 +34,7 @@ from services.openai_realtime.events import (
normalize_turn_detection,
require_event,
)
from services.openai_realtime.schemas import SERVER_EVENT_TYPES
from services.openai_realtime.session import OpenAIRealtimeSession
from services.pipecat.turn_config import create_vad_params
from services.realtime.protocol import PipelineProtocolRuntime
@@ -47,7 +48,10 @@ def _event_id() -> str:
def _server_event(event_type: str, **payload: Any) -> dict[str, Any]:
return {"type": event_type, "event_id": _event_id(), **payload}
event = {"type": event_type, "event_id": _event_id(), **payload}
if event_type.startswith("x.interactive_media."):
event["version"] = "1"
return event
def _decode_base64(value: object, *, param: str) -> bytes:
@@ -129,9 +133,13 @@ class OpenAIRealtimeInputProcessor(FrameProcessor):
),
direction,
)
client_event: dict[str, Any] | None = None
try:
await self._bridge.handle_client_event(require_event(frame.message))
client_event = require_event(frame.message)
await self._bridge.handle_client_event(client_event)
except RealtimeEventError as exc:
if exc.event_id is None and client_event is not None:
exc.event_id = str(client_event.get("event_id") or "") or None
await self._bridge.emit(error_event(exc))
except Exception as exc: # noqa: BLE001 - event errors must stay connection-local
client_event_id = (
@@ -682,10 +690,16 @@ class OpenAIRealtimeBridge:
def translate_server_message(self, message: dict[str, Any]) -> list[dict[str, Any]]:
message_type = str(message.get("type") or "")
if message_type == "error":
return [message]
if message_type in SERVER_EVENT_TYPES:
event = dict(message)
event.setdefault("event_id", _event_id())
if message_type.startswith("x.interactive_media."):
event["version"] = "1"
return [event]
if "." in message_type:
return [message]
# Dotted wire events outside the documented subset must not leak
# through from a provider-specific adapter.
return []
if message_type == "transcript":
return self._translate_transcript(message)
if message_type == "assistant-text-start":
@@ -903,7 +917,10 @@ class OpenAIRealtimeBridge:
"workflow-variables": "x.interactive_media.workflow.variables.updated",
"workflow-error": "x.interactive_media.workflow.error",
}
return _server_event(names[kind], **{k: v for k, v in message.items() if k != "type"})
return _server_event(
names[kind],
**{k: v for k, v in message.items() if k != "type"},
)
if kind in {"handoff-requested", "call-ended"}:
if "handoff" not in self.session.capabilities:
return None
@@ -912,5 +929,8 @@ class OpenAIRealtimeBridge:
if kind == "handoff-requested"
else "x.interactive_media.call.ended"
)
return _server_event(event_type, **{k: v for k, v in message.items() if k != "type"})
return _server_event(
event_type,
**{k: v for k, v in message.items() if k != "type"},
)
return None
+18 -1
View File
@@ -6,6 +6,9 @@ from dataclasses import dataclass
from typing import Any
from uuid import uuid4
from pydantic import ValidationError
from services.openai_realtime.schemas import REALTIME_CLIENT_EVENT_ADAPTER
SUPPORTED_CLIENT_EVENTS = {
"session.update",
@@ -51,7 +54,21 @@ def require_event(message: object) -> dict[str, Any]:
param="type",
event_id=str(message.get("event_id") or "") or None,
)
return message
try:
validated = REALTIME_CLIENT_EVENT_ADAPTER.validate_python(message)
except ValidationError as exc:
first = exc.errors(include_url=False)[0]
location = list(first.get("loc") or [])
if location and location[0] == event_type:
location.pop(0)
param = ".".join(str(part) for part in location) or None
raise RealtimeEventError(
str(first.get("msg") or "Event does not match the schema"),
code="invalid_event",
param=param,
event_id=str(message.get("event_id") or "") or None,
) from exc
return validated.model_dump(exclude_none=True)
def assistant_id_from_model(model: object) -> str:
+236
View File
@@ -0,0 +1,236 @@
"""Public Pydantic and JSON Schema contract for OpenAI Realtime subset v1."""
from __future__ import annotations
from typing import Annotated, Any, Literal, get_args
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
class _ExtensibleModel(BaseModel):
model_config = ConfigDict(extra="allow")
class RealtimeSessionRequest(_ExtensibleModel):
type: Literal["realtime"] | None = None
model: str
output_modalities: list[Literal["audio", "text"]] | None = None
audio: dict[str, Any] | None = None
turn_detection: dict[str, Any] | None = None
instructions: str | None = Field(
default=None,
description="Platform-owned in subset v1; requests containing it are rejected.",
)
voice: str | None = Field(
default=None,
description="Platform-owned in subset v1; requests containing it are rejected.",
)
tools: list[dict[str, Any]] | None = Field(
default=None,
description="Platform-owned in subset v1; requests containing it are rejected.",
)
tool_choice: Any | None = Field(
default=None,
description="Platform-owned in subset v1; requests containing it are rejected.",
)
class RealtimeClientSecretRequest(_ExtensibleModel):
session: RealtimeSessionRequest
RealtimeClientSecretBody = RealtimeClientSecretRequest | RealtimeSessionRequest
class RealtimeClientSecretResponse(BaseModel):
model_config = ConfigDict(
extra="forbid",
json_schema_extra={
"examples": [
{
"value": "ek-rt-...",
"expires_at": 1788739260,
"session": {
"type": "realtime",
"model": "assistant:asst_xxx",
"output_modalities": ["audio"],
},
}
]
},
)
value: str
expires_at: int
session: dict[str, Any]
class _ClientEvent(_ExtensibleModel):
event_id: str | None = None
class SessionUpdateEvent(_ClientEvent):
type: Literal["session.update"]
session: dict[str, Any]
class ConversationItemCreateEvent(_ClientEvent):
type: Literal["conversation.item.create"]
item: dict[str, Any]
previous_item_id: str | None = None
class ConversationItemTruncateEvent(_ClientEvent):
type: Literal["conversation.item.truncate"]
item_id: str
content_index: int = Field(default=0, ge=0)
audio_end_ms: int = Field(ge=0)
class InputAudioBufferAppendEvent(_ClientEvent):
type: Literal["input_audio_buffer.append"]
audio: str
class InputAudioBufferCommitEvent(_ClientEvent):
type: Literal["input_audio_buffer.commit"]
class InputAudioBufferClearEvent(_ClientEvent):
type: Literal["input_audio_buffer.clear"]
class OutputAudioBufferClearEvent(_ClientEvent):
type: Literal["output_audio_buffer.clear"]
class ResponseCreateEvent(_ClientEvent):
type: Literal["response.create"]
response: dict[str, Any] | None = None
class ResponseCancelEvent(_ClientEvent):
type: Literal["response.cancel"]
response_id: str | None = None
class CapabilitiesUpdateEvent(_ClientEvent):
type: Literal["x.interactive_media.capabilities.update"]
version: Literal["1"]
capabilities: list[str]
class SessionVariablesUpdateEvent(_ClientEvent):
type: Literal["x.interactive_media.session.variables.update"]
version: Literal["1"]
variables: dict[str, Any]
RealtimeClientEvent = Annotated[
SessionUpdateEvent
| ConversationItemCreateEvent
| ConversationItemTruncateEvent
| InputAudioBufferAppendEvent
| InputAudioBufferCommitEvent
| InputAudioBufferClearEvent
| OutputAudioBufferClearEvent
| ResponseCreateEvent
| ResponseCancelEvent
| CapabilitiesUpdateEvent
| SessionVariablesUpdateEvent,
Field(discriminator="type"),
]
REALTIME_CLIENT_EVENT_ADAPTER = TypeAdapter(RealtimeClientEvent)
RealtimeServerEventType = Literal[
"session.created",
"session.updated",
"conversation.item.added",
"conversation.item.done",
"conversation.item.truncated",
"conversation.item.input_audio_transcription.completed",
"input_audio_buffer.committed",
"input_audio_buffer.cleared",
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
"output_audio_buffer.cleared",
"response.created",
"response.output_item.added",
"response.output_item.done",
"response.content_part.added",
"response.content_part.done",
"response.output_text.delta",
"response.output_text.done",
"response.output_audio.delta",
"response.output_audio.done",
"response.output_audio_transcript.delta",
"response.output_audio_transcript.done",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.done",
"error",
"x.interactive_media.capabilities.updated",
"x.interactive_media.session.variables.updated",
"x.interactive_media.workflow.node_active",
"x.interactive_media.workflow.event",
"x.interactive_media.workflow.variables.updated",
"x.interactive_media.workflow.error",
"x.interactive_media.call.handoff_requested",
"x.interactive_media.call.ended",
]
SERVER_EVENT_TYPES = get_args(RealtimeServerEventType)
EXTENSION_SERVER_EVENT_TYPES = tuple(
event_type
for event_type in SERVER_EVENT_TYPES
if event_type.startswith("x.interactive_media.")
)
class RealtimeServerEvent(_ExtensibleModel):
type: RealtimeServerEventType
event_id: str
def realtime_client_event_json_schema() -> dict[str, Any]:
schema = REALTIME_CLIENT_EVENT_ADAPTER.json_schema(
ref_template="#/$defs/{model}",
)
schema.update(
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://interactive-media.local/schemas/realtime-client-events-v1.json",
"title": "Interactive Media OpenAI Realtime client events v1",
}
)
return schema
def realtime_server_event_json_schema() -> dict[str, Any]:
schema = RealtimeServerEvent.model_json_schema()
schema.update(
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://interactive-media.local/schemas/realtime-server-events-v1.json",
"title": "Interactive Media OpenAI Realtime server events v1",
}
)
schema["allOf"] = [
{
"if": {
"properties": {
"type": {"enum": list(EXTENSION_SERVER_EVENT_TYPES)},
},
"required": ["type"],
},
"then": {
"properties": {
"version": {"const": "1", "type": "string"},
},
"required": ["version"],
},
}
]
return schema
+84 -13
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
from db.models import OpenAIResponse
from services.openai_api import error_payload as public_error_payload
LOCKED_FIELDS = {"instructions", "tools", "tool_choice"}
@@ -54,26 +56,26 @@ class ResponseRequest:
def error_payload(error: ResponsesAPIError) -> dict[str, Any]:
return {
"error": {
"type": "invalid_request_error",
"code": error.code,
"message": error.message,
"param": error.param,
}
}
return public_error_payload(
error.message,
code=error.code,
param=error.param,
)
def _text_content(value: object, *, role: str) -> list[dict[str, str]]:
def _text_content(value: object, *, role: str) -> list[dict[str, Any]]:
expected = "input_text" if role == "user" else "output_text"
if isinstance(value, str):
text = value.strip()
if not text:
raise ResponsesAPIError("Message content cannot be empty", param="input")
return [{"type": expected, "text": text}]
item: dict[str, Any] = {"type": expected, "text": text}
if expected == "output_text":
item["annotations"] = []
return [item]
if not isinstance(value, list) or not value:
raise ResponsesAPIError("Message content must be text", param="input")
result: list[dict[str, str]] = []
result: list[dict[str, Any]] = []
for part in value:
if not isinstance(part, dict) or part.get("type") != expected:
raise ResponsesAPIError(
@@ -82,7 +84,10 @@ def _text_content(value: object, *, role: str) -> list[dict[str, str]]:
text = str(part.get("text") or "").strip()
if not text:
raise ResponsesAPIError("Message text cannot be empty", param="input")
result.append({"type": expected, "text": text})
item: dict[str, Any] = {"type": expected, "text": text}
if expected == "output_text":
item["annotations"] = []
result.append(item)
return result
@@ -90,9 +95,11 @@ def normalize_input(value: object) -> list[dict[str, Any]]:
if isinstance(value, str):
return [
{
"id": new_id("msg"),
"type": "message",
"role": "user",
"content": _text_content(value, role="user"),
"status": "completed",
}
]
if not isinstance(value, list) or not value:
@@ -105,6 +112,13 @@ def normalize_input(value: object) -> list[dict[str, Any]]:
raise ResponsesAPIError("Input item must be an object", param=param)
item_type = str(raw.get("type") or "message")
if item_type == "message":
unknown_fields = sorted(set(raw) - {"type", "role", "content"})
if unknown_fields:
raise ResponsesAPIError(
f"Unsupported message field: {unknown_fields[0]}",
code="unsupported_parameter",
param=f"{param}.{unknown_fields[0]}",
)
role = str(raw.get("role") or "user")
if role not in {"user", "assistant"}:
raise ResponsesAPIError(
@@ -112,13 +126,22 @@ def normalize_input(value: object) -> list[dict[str, Any]]:
)
items.append(
{
"id": new_id("msg"),
"type": "message",
"role": role,
"content": _text_content(raw.get("content"), role=role),
"status": "completed",
}
)
continue
if item_type == "function_call_output":
unknown_fields = sorted(set(raw) - {"type", "call_id", "output"})
if unknown_fields:
raise ResponsesAPIError(
f"Unsupported function_call_output field: {unknown_fields[0]}",
code="unsupported_parameter",
param=f"{param}.{unknown_fields[0]}",
)
call_id = str(raw.get("call_id") or "").strip()
if not call_id:
raise ResponsesAPIError(
@@ -130,9 +153,11 @@ def normalize_input(value: object) -> list[dict[str, Any]]:
output = json.dumps(output, ensure_ascii=False, default=str)
items.append(
{
"id": new_id("fc"),
"type": "function_call_output",
"call_id": call_id,
"output": output,
"status": "completed",
}
)
continue
@@ -188,6 +213,20 @@ def parse_request(body: object) -> ResponseRequest:
"variables must be an object",
param="x_interactive_media.variables",
)
unknown_extensions = sorted(set(extension) - {"version", "variables"})
if unknown_extensions:
raise ResponsesAPIError(
f"Unsupported extension parameter: {unknown_extensions[0]}",
code="unsupported_parameter",
param=f"x_interactive_media.{unknown_extensions[0]}",
)
extension_version = extension.get("version")
if extension_version not in (None, "1"):
raise ResponsesAPIError(
"x_interactive_media.version must be 1",
code="unsupported_extension_version",
param="x_interactive_media.version",
)
raw_model = body.get("model")
if raw_model is not None and not isinstance(raw_model, str):
raise ResponsesAPIError("model must be a string", param="model")
@@ -217,6 +256,38 @@ def new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
def request_fingerprint(body: dict[str, Any]) -> str:
"""Return a stable digest for Idempotency-Key conflict detection."""
canonical = json.dumps(
body,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def normalize_idempotency_key(value: str | None) -> str | None:
if value is None:
return None
key = value.strip()
if not key:
raise ResponsesAPIError(
"Idempotency-Key must not be empty",
code="invalid_idempotency_key",
param="Idempotency-Key",
)
if len(key) > 255:
raise ResponsesAPIError(
"Idempotency-Key must be at most 255 characters",
code="invalid_idempotency_key",
param="Idempotency-Key",
)
return key
def response_payload(row: OpenAIResponse) -> dict[str, Any]:
return {
"id": row.id,
@@ -237,5 +308,5 @@ def response_payload(row: OpenAIResponse) -> dict[str, Any]:
"tool_choice": "auto",
"tools": [],
"metadata": {},
"usage": None,
"usage": getattr(row, "usage", None),
}
+82 -12
View File
@@ -28,8 +28,10 @@ from pipecat.frames.frames import (
LLMMessagesAppendFrame,
LLMTextFrame,
ManuallySwitchServiceFrame,
MetricsFrame,
TTSSpeakFrame,
)
from pipecat.metrics.metrics import LLMTokenUsage, LLMUsageMetricsData
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
@@ -63,6 +65,19 @@ class ResponseRunError(RuntimeError):
DeltaCallback = Callable[[str], Awaitable[None]]
def _normalized_tool_arguments(value: object) -> object:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def _tool_arguments_equal(left: object, right: object) -> bool:
return _normalized_tool_arguments(left) == _normalized_tool_arguments(right)
@dataclass(frozen=True)
class ResponseRunResult:
text: str
@@ -70,6 +85,27 @@ class ResponseRunResult:
context_messages: list[dict[str, Any]]
dynamic_variables: dict[str, Any]
runtime_state: dict[str, Any]
usage: dict[str, Any] | None
def _usage_payload(values: list[LLMTokenUsage]) -> dict[str, Any] | None:
"""Combine Provider-reported LLM usage without estimating missing values."""
if not values:
return None
return {
"input_tokens": sum(value.prompt_tokens for value in values),
"input_tokens_details": {
"cached_tokens": sum(
value.cache_read_input_tokens or 0 for value in values
),
},
"output_tokens": sum(value.completion_tokens for value in values),
"output_tokens_details": {
"reasoning_tokens": sum(value.reasoning_tokens or 0 for value in values),
},
"total_tokens": sum(value.total_tokens for value in values),
}
@dataclass
@@ -101,6 +137,7 @@ class _CaptureProcessor(FrameProcessor):
self.window = _ResponseWindow()
self.deltas: list[str] = []
self.outputs: list[str] = []
self.llm_usage: list[LLMTokenUsage] = []
self.error: str | None = None
def reset(self) -> None:
@@ -143,6 +180,12 @@ class _CaptureProcessor(FrameProcessor):
self.error = frame.error or "Pipeline execution failed"
self.window.pending_tools.clear()
self.window.touch()
elif isinstance(frame, MetricsFrame):
self.llm_usage.extend(
metric.value
for metric in frame.data
if isinstance(metric, LLMUsageMetricsData)
)
elif isinstance(frame, _BoundaryFrame):
frame.reached.set()
self.window.touch()
@@ -162,6 +205,13 @@ class ResponsesClientToolPort:
def supports(self, _function_name: str) -> bool:
return True
@staticmethod
def _arguments_match(pending: dict[str, Any], arguments: dict[str, Any]) -> bool:
original = pending.get("arguments")
if original is None:
return True
return _tool_arguments_equal(original, arguments)
async def call(
self,
function_name: str,
@@ -174,7 +224,10 @@ class ResponsesClientToolPort:
) -> dict[str, Any]:
del timeout_seconds, wait_for_response, response_wait_mode, interrupt_on_result
for index, pending in enumerate(self._results):
if pending.get("name") == function_name:
if (
pending.get("name") == function_name
and self._arguments_match(pending, arguments)
):
self._results.pop(index)
return dict(pending.get("result") or {})
item = {
@@ -207,18 +260,29 @@ def _context_snapshot(
]
if fallback_messages:
messages.extend(deepcopy(fallback_messages))
candidates = [
tool_call
for message in messages
for tool_call in message.get("tool_calls") or []
]
used_candidates: set[int] = set()
for public_call in function_calls:
matched = False
for message in reversed(messages):
for tool_call in reversed(message.get("tool_calls") or []):
function = tool_call.get("function") or {}
if function.get("name") != public_call["name"]:
continue
tool_call["id"] = public_call["call_id"]
matched = True
break
if matched:
break
for index, tool_call in enumerate(candidates):
if index in used_candidates:
continue
function = tool_call.get("function") or {}
if function.get("name") != public_call["name"]:
continue
if not _tool_arguments_equal(
function.get("arguments"),
public_call.get("arguments"),
):
continue
tool_call["id"] = public_call["call_id"]
used_candidates.add(index)
matched = True
break
if not matched:
messages.append(
{
@@ -368,7 +432,10 @@ class ResponsesTextRunner:
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(enable_metrics=False),
params=PipelineParams(
enable_metrics=False,
enable_usage_metrics=True,
),
enable_rtvi=False,
enable_turn_tracking=False,
idle_timeout_secs=None,
@@ -562,6 +629,7 @@ class ResponsesTextRunner:
),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
usage=_usage_payload(capture.llm_usage),
)
capture.reset()
@@ -589,6 +657,7 @@ class ResponsesTextRunner:
),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
usage=_usage_payload(capture.llm_usage),
)
text = (capture.outputs[-1] if capture.outputs else "".join(capture.deltas)).strip()
if not text:
@@ -599,6 +668,7 @@ class ResponsesTextRunner:
context_messages=_context_snapshot(context, function_calls=[]),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
usage=_usage_payload(capture.llm_usage),
)
finally:
if not worker.has_finished():
@@ -0,0 +1,314 @@
"""Pydantic contract for the supported Responses API surface."""
from __future__ import annotations
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
ItemStatus = Literal["in_progress", "completed", "incomplete"]
ResponseStatus = Literal[
"in_progress",
"completed",
"failed",
"cancelled",
"incomplete",
]
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class InputText(_StrictModel):
type: Literal["input_text"] = "input_text"
text: str
class OutputTextInput(_StrictModel):
type: Literal["output_text"] = "output_text"
text: str
annotations: list[dict[str, Any]] = Field(default_factory=list)
MessageContent = Annotated[
InputText | OutputTextInput,
Field(discriminator="type"),
]
class ResponseInputMessage(_StrictModel):
type: Literal["message"] = "message"
role: Literal["user", "assistant"] = "user"
content: str | list[MessageContent]
class ResponseFunctionCallOutputInput(_StrictModel):
type: Literal["function_call_output"] = "function_call_output"
call_id: str
output: Any
ResponseInputItem = Annotated[
ResponseInputMessage | ResponseFunctionCallOutputInput,
Field(discriminator="type"),
]
class InteractiveMediaRequestExtension(_StrictModel):
version: Literal["1"] | None = Field(
default=None,
description="Interactive Media extension version. Omit or use `1`.",
)
variables: dict[str, Any] = Field(default_factory=dict)
class ResponseCreateRequest(BaseModel):
"""Supported request fields; extras are preserved for semantic rejection."""
model_config = ConfigDict(
extra="allow",
json_schema_extra={
"examples": [
{
"model": "assistant:asst_xxx",
"input": "请介绍一下这个产品",
"store": True,
},
{
"previous_response_id": "resp_xxx",
"input": "继续说明",
"stream": True,
},
]
},
)
model: str | None = Field(
default=None,
examples=["assistant:asst_xxx"],
description="Required on the first turn; inherited on later turns.",
)
previous_response_id: str | None = None
input: str | list[ResponseInputItem]
store: bool | None = Field(
default=None,
description="Stateful subset: omit this field or set it to true.",
)
stream: bool = False
x_interactive_media: InteractiveMediaRequestExtension | None = None
@model_validator(mode="before")
@classmethod
def default_message_discriminator(cls, value: Any) -> Any:
"""Accept the official SDK's easy input message with omitted `type`."""
if not isinstance(value, dict) or not isinstance(value.get("input"), list):
return value
copied = dict(value)
copied["input"] = [
({"type": "message", **item} if isinstance(item, dict) and "type" not in item else item)
for item in value["input"]
]
return copied
class ResponseOutputText(_StrictModel):
type: Literal["output_text"] = "output_text"
text: str
annotations: list[dict[str, Any]] = Field(default_factory=list)
logprobs: list[dict[str, Any]] | None = None
class ResponseOutputMessage(_StrictModel):
id: str
type: Literal["message"] = "message"
status: ItemStatus
role: Literal["assistant"] = "assistant"
content: list[ResponseOutputText]
class ResponseFunctionCall(_StrictModel):
id: str
type: Literal["function_call"] = "function_call"
status: ItemStatus
call_id: str
name: str
arguments: str
ResponseOutputItem = Annotated[
ResponseOutputMessage | ResponseFunctionCall,
Field(discriminator="type"),
]
class ResponseError(_StrictModel):
code: str
message: str
class ResponseUsage(_StrictModel):
input_tokens: int
output_tokens: int
total_tokens: int
input_tokens_details: dict[str, Any] = Field(default_factory=dict)
output_tokens_details: dict[str, Any] = Field(default_factory=dict)
class ResponseObject(_StrictModel):
model_config = ConfigDict(
extra="forbid",
json_schema_extra={
"examples": [
{
"id": "resp_xxx",
"object": "response",
"created_at": 1788739200,
"completed_at": 1788739201,
"status": "completed",
"error": None,
"incomplete_details": None,
"instructions": None,
"model": "assistant:asst_xxx",
"output": [
{
"id": "msg_xxx",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "这是一个实时对话助手。",
"annotations": [],
}
],
}
],
"parallel_tool_calls": False,
"previous_response_id": None,
"store": True,
"tool_choice": "auto",
"tools": [],
"metadata": {},
"usage": None,
}
]
},
)
id: str
object: Literal["response"] = "response"
created_at: int
completed_at: int | None = None
status: ResponseStatus
error: ResponseError | None = None
incomplete_details: dict[str, Any] | None = None
instructions: None = None
model: str | None
output: list[ResponseOutputItem]
parallel_tool_calls: Literal[False] = False
previous_response_id: str | None = None
store: Literal[True] = True
tool_choice: Literal["auto"] = "auto"
tools: list[Any] = Field(default_factory=list)
metadata: dict[str, str] = Field(default_factory=dict)
usage: ResponseUsage | None = None
class ResponseInputMessageItem(_StrictModel):
id: str
type: Literal["message"] = "message"
role: Literal["user", "assistant"]
content: list[MessageContent]
status: ItemStatus = "completed"
class ResponseFunctionCallOutputItem(_StrictModel):
id: str
type: Literal["function_call_output"] = "function_call_output"
call_id: str
output: str
status: ItemStatus = "completed"
ResponseStoredInputItem = Annotated[
ResponseInputMessageItem | ResponseFunctionCallOutputItem,
Field(discriminator="type"),
]
class ResponseInputItemList(_StrictModel):
model_config = ConfigDict(
extra="forbid",
json_schema_extra={
"examples": [
{
"object": "list",
"data": [
{
"id": "msg_input_xxx",
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "你好"}
],
"status": "completed",
}
],
"first_id": "msg_input_xxx",
"last_id": "msg_input_xxx",
"has_more": False,
}
]
},
)
object: Literal["list"] = "list"
data: list[ResponseStoredInputItem]
first_id: str | None = None
last_id: str | None = None
has_more: bool
class ModelObject(_StrictModel):
id: str
object: Literal["model"] = "model"
created: int
owned_by: str = "interactive-media"
name: str
capabilities: list[Literal["responses", "realtime"]]
assistant_id: str
assistant_type: str
runtime_mode: str
class ModelList(_StrictModel):
model_config = ConfigDict(
extra="forbid",
json_schema_extra={
"examples": [
{
"object": "list",
"data": [
{
"id": "assistant:asst_xxx",
"object": "model",
"created": 1788739200,
"owned_by": "interactive-media",
"name": "产品顾问",
"capabilities": ["responses", "realtime"],
"assistant_id": "asst_xxx",
"assistant_type": "prompt",
"runtime_mode": "pipeline",
}
],
}
]
},
)
object: Literal["list"] = "list"
data: list[ModelObject]
+157 -10
View File
@@ -18,9 +18,11 @@ from services.openai_responses.protocol import (
)
from services.realtime.launcher import resolve_assistant_config
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
CONTINUABLE_STATUSES = {"completed"}
IDEMPOTENCY_CONSTRAINT = "uq_openai_response_idempotency"
@dataclass(frozen=True)
@@ -33,6 +35,26 @@ class PreparedResponse:
client_tool_results: list[dict[str, Any]]
def _integrity_constraint_name(error: IntegrityError) -> str | None:
current = getattr(error, "orig", None)
seen: set[int] = set()
while current is not None and id(current) not in seen:
seen.add(id(current))
direct = getattr(current, "constraint_name", None)
if direct:
return str(direct)
diagnostic = getattr(current, "diag", None)
value = getattr(diagnostic, "constraint_name", None)
if value:
return str(value)
current = getattr(current, "__cause__", None) or getattr(
current,
"__context__",
None,
)
return None
def _content_text(item: dict[str, Any]) -> str:
return "\n".join(
str(part.get("text") or "")
@@ -97,6 +119,7 @@ async def _load_chain(
seen: set[str] = set()
async with SessionLocal() as db:
current_id: str | None = response_id
is_requested_response = True
while current_id:
if current_id in seen or len(chain) >= 1000:
raise ResponsesAPIError(
@@ -104,12 +127,17 @@ async def _load_chain(
)
seen.add(current_id)
row = await db.get(OpenAIResponse, current_id)
if row is None or row.api_key_id != api_key_id:
if (
row is None
or row.api_key_id != api_key_id
or (is_requested_response and row.deleted_at is not None)
):
raise ResponsesAPIError(
"Response not found", code="response_not_found", status_code=404
)
chain.append(row)
current_id = row.previous_response_id
is_requested_response = False
chain.reverse()
return chain
@@ -158,17 +186,23 @@ def _client_tool_results(
chain: list[OpenAIResponse],
current_items: list[dict[str, Any]],
) -> list[dict[str, Any]]:
calls = {
str(item.get("call_id") or ""): item
calls = [
item
for row in chain
for item in row.output_items or []
if item.get("type") == "function_call"
]
outputs = {
str(item.get("call_id") or ""): item
for item in current_items
if item.get("type") == "function_call_output"
}
results: list[dict[str, Any]] = []
for item in current_items:
if item.get("type") != "function_call_output":
for call in calls:
call_id = str(call.get("call_id") or "")
item = outputs.get(call_id)
if item is None:
continue
call = calls.get(str(item.get("call_id") or "")) or {}
output = str(item.get("output") or "")
try:
value = json.loads(output)
@@ -178,8 +212,9 @@ def _client_tool_results(
value = {"status": "ok", "data": value}
results.append(
{
"call_id": item.get("call_id"),
"call_id": call_id,
"name": call.get("name"),
"arguments": call.get("arguments"),
"result": value,
}
)
@@ -190,6 +225,8 @@ async def prepare_response(
request: ResponseRequest,
*,
api_key_id: str,
idempotency_key: str | None = None,
request_hash: str | None = None,
) -> PreparedResponse:
chain: list[OpenAIResponse] = []
parent: OpenAIResponse | None = None
@@ -221,7 +258,14 @@ async def prepare_response(
)
assistant_id = parent.assistant_id
if request.model:
requested_id = assistant_id_from_model(request.model)
try:
requested_id = assistant_id_from_model(request.model)
except ValueError as exc:
raise ResponsesAPIError(
str(exc),
code="invalid_model",
param="model",
) from exc
if requested_id != assistant_id:
raise ResponsesAPIError(
"model must match the previous response",
@@ -307,6 +351,8 @@ async def prepare_response(
context_messages=[],
runtime_state={},
dynamic_variables=effective_variables,
idempotency_key=idempotency_key,
request_hash=request_hash,
)
async with SessionLocal() as db:
if parent is None:
@@ -334,7 +380,21 @@ async def prepare_response(
conversation.status = "active"
conversation.ended_at = None
db.add(row)
await db.commit()
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
if (
idempotency_key is not None
and _integrity_constraint_name(exc) == IDEMPOTENCY_CONSTRAINT
):
raise ResponsesAPIError(
"Idempotency-Key is already in use",
code="idempotency_key_in_use",
param="Idempotency-Key",
status_code=409,
) from exc
raise
await db.refresh(row)
return PreparedResponse(
@@ -351,16 +411,99 @@ async def get_response(
response_id: str,
*,
api_key_id: str,
include_deleted: bool = False,
) -> OpenAIResponse:
async with SessionLocal() as db:
row = await db.get(OpenAIResponse, response_id)
if row is None or row.api_key_id != api_key_id:
if (
row is None
or row.api_key_id != api_key_id
or (row.deleted_at is not None and not include_deleted)
):
raise ResponsesAPIError(
"Response not found", code="response_not_found", status_code=404
)
return row
async def get_idempotent_response(
*,
api_key_id: str,
idempotency_key: str | None,
request_hash: str,
) -> OpenAIResponse | None:
"""Return a terminal replay or reject conflicting reuse of a request key."""
if idempotency_key is None:
return None
async with SessionLocal() as db:
row = (
await db.execute(
select(OpenAIResponse).where(
OpenAIResponse.api_key_id == api_key_id,
OpenAIResponse.idempotency_key == idempotency_key,
)
)
).scalar_one_or_none()
if row is None:
return None
if row.request_hash != request_hash:
raise ResponsesAPIError(
"Idempotency-Key was already used with a different request",
code="idempotency_key_conflict",
param="Idempotency-Key",
status_code=409,
)
if row.deleted_at is not None:
raise ResponsesAPIError(
"The response created by this Idempotency-Key was deleted",
code="idempotency_response_deleted",
param="Idempotency-Key",
status_code=409,
)
if row.status == "in_progress":
raise ResponsesAPIError(
"A request with this Idempotency-Key is still in progress",
code="idempotency_key_in_use",
param="Idempotency-Key",
status_code=409,
)
return row
async def delete_response(
response_id: str,
*,
api_key_id: str,
) -> OpenAIResponse:
"""Create a tombstone while retaining state needed by existing branches."""
async with SessionLocal() as db:
row = await db.get(
OpenAIResponse,
response_id,
with_for_update=True,
)
if (
row is None
or row.api_key_id != api_key_id
or row.deleted_at is not None
):
raise ResponsesAPIError(
"Response not found", code="response_not_found", status_code=404
)
if row.status == "in_progress":
raise ResponsesAPIError(
"An in-progress response cannot be deleted",
code="response_not_ready",
status_code=409,
)
row.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(row)
return row
async def finish_response(
response_id: str,
*,
@@ -370,6 +513,7 @@ async def finish_response(
dynamic_variables: dict[str, Any] | None = None,
runtime_state: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
usage: dict[str, Any] | None = None,
) -> OpenAIResponse:
now = datetime.now(UTC)
async with SessionLocal() as db:
@@ -384,6 +528,7 @@ async def finish_response(
if runtime_state is not None:
row.runtime_state = runtime_state
row.error = error
row.usage = usage
row.completed_at = now
conversation = (
@@ -445,6 +590,8 @@ async def finish_response(
await db.commit()
await db.refresh(row)
return row
async def recover_interrupted_responses() -> None:
"""Mark generations interrupted by a process restart as cancelled."""
now = datetime.now(UTC)
+193
View File
@@ -0,0 +1,193 @@
"""Wire-format helpers for the public Responses API subset."""
from __future__ import annotations
import json
from typing import Any
from db.models import OpenAIResponse
def encode_sse(event: dict[str, Any]) -> bytes:
"""Encode one named Responses event as an SSE record."""
return (
f"event: {event['type']}\n"
f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
).encode("utf-8")
class SSEEventSequence:
"""Attach the monotonically increasing sequence required by SDK models."""
def __init__(self) -> None:
self._next = 0
def encode(self, event_type: str, **payload: Any) -> bytes:
event = {
"type": event_type,
"sequence_number": self._next,
**payload,
}
self._next += 1
return encode_sse(event)
def assistant_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 build_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(assistant_text_item(text_item_id, text))
items.extend(function_calls)
return items
def output_lifecycle_events(
row: OpenAIResponse,
*,
started_text_items: set[str],
replay_text: bool,
) -> list[dict[str, Any]]:
"""Build terminal item events shared by live and idempotent streams."""
events: list[dict[str, Any]] = []
for output_index, item in enumerate(row.output_items or []):
if item.get("type") != "message":
pending_item = {**item, "status": "in_progress"}
if item.get("type") == "function_call":
pending_item["arguments"] = ""
events.append(
{
"type": "response.output_item.added",
"output_index": output_index,
"item": pending_item,
}
)
if item.get("type") == "function_call":
item_id = str(item.get("id") or "")
arguments = str(item.get("arguments") or "")
if arguments:
events.append(
{
"type": "response.function_call_arguments.delta",
"item_id": item_id,
"output_index": output_index,
"delta": arguments,
}
)
events.append(
{
"type": "response.function_call_arguments.done",
"item_id": item_id,
"output_index": output_index,
"name": str(item.get("name") or ""),
"arguments": arguments,
}
)
events.append(
{
"type": "response.output_item.done",
"output_index": output_index,
"item": item,
}
)
continue
item_id = str(item["id"])
content = item.get("content") or []
part = content[0] if content else {
"type": "output_text",
"text": "",
"annotations": [],
}
text = str(part.get("text") or "")
if item_id not in started_text_items:
events.extend(
[
{
"type": "response.output_item.added",
"output_index": output_index,
"item": {**item, "status": "in_progress", "content": []},
},
{
"type": "response.content_part.added",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": {
"type": "output_text",
"text": "",
"annotations": [],
},
},
]
)
started_text_items.add(item_id)
if replay_text and text:
events.append(
{
"type": "response.output_text.delta",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"delta": text,
"logprobs": [],
}
)
events.extend(
[
{
"type": "response.output_text.done",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"text": text,
"logprobs": [],
},
{
"type": "response.content_part.done",
"item_id": item_id,
"output_index": output_index,
"content_index": 0,
"part": part,
},
{
"type": "response.output_item.done",
"output_index": output_index,
"item": item,
},
]
)
return events
def stored_input_items(row: OpenAIResponse) -> list[dict[str, Any]]:
"""Backfill stable wire IDs for records created before contract v1."""
result: list[dict[str, Any]] = []
for index, stored in enumerate(row.input_items or []):
item = dict(stored)
item.setdefault("id", f"item_{row.id}_{index}")
item.setdefault("status", "completed")
result.append(item)
return result
@@ -0,0 +1,172 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from services.openai_responses.protocol import ResponsesAPIError
from services.openai_responses.state import (
IDEMPOTENCY_CONSTRAINT,
_integrity_constraint_name,
_load_chain,
delete_response,
get_idempotent_response,
get_response,
)
from sqlalchemy.exc import IntegrityError
class _ScalarResult:
def __init__(self, value):
self.value = value
def scalar_one_or_none(self):
return self.value
class _FakeSession:
def __init__(self, rows, *, selected=None):
self.rows = rows
self.selected = selected
self.commits = 0
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def get(self, _model, row_id, **_kwargs):
return self.rows.get(row_id)
async def execute(self, _statement):
return _ScalarResult(self.selected)
async def commit(self):
self.commits += 1
async def refresh(self, _row):
return None
def _row(
row_id,
*,
api_key_id="key_a",
previous_response_id=None,
deleted_at=None,
status="completed",
):
return SimpleNamespace(
id=row_id,
api_key_id=api_key_id,
previous_response_id=previous_response_id,
deleted_at=deleted_at,
status=status,
idempotency_key=None,
request_hash=None,
)
class ResponseTombstoneTests(unittest.IsolatedAsyncioTestCase):
async def test_deleted_ancestor_remains_available_to_existing_child_chain(self):
root = _row("resp_root", deleted_at=object())
child = _row("resp_child", previous_response_id=root.id)
rows = {root.id: root, child.id: child}
with patch(
"services.openai_responses.state.SessionLocal",
side_effect=lambda: _FakeSession(rows),
):
chain = await _load_chain(child.id, api_key_id="key_a")
self.assertEqual([row.id for row in chain], [root.id, child.id])
with self.assertRaises(ResponsesAPIError) as raised:
await _load_chain(root.id, api_key_id="key_a")
self.assertEqual(raised.exception.status_code, 404)
async def test_deleted_and_cross_key_resources_are_hidden(self):
rows = {
"resp_deleted": _row("resp_deleted", deleted_at=object()),
"resp_other": _row("resp_other", api_key_id="key_b"),
}
with patch(
"services.openai_responses.state.SessionLocal",
side_effect=lambda: _FakeSession(rows),
):
for response_id in rows:
with self.assertRaises(ResponsesAPIError) as raised:
await get_response(response_id, api_key_id="key_a")
self.assertEqual(raised.exception.status_code, 404)
async def test_delete_rejects_in_progress_and_tombstones_terminal_response(self):
running = _row("resp_running", status="in_progress")
completed = _row("resp_completed")
rows = {running.id: running, completed.id: completed}
with patch(
"services.openai_responses.state.SessionLocal",
side_effect=lambda: _FakeSession(rows),
):
with self.assertRaises(ResponsesAPIError) as raised:
await delete_response(running.id, api_key_id="key_a")
self.assertEqual(raised.exception.status_code, 409)
deleted = await delete_response(completed.id, api_key_id="key_a")
self.assertIsNotNone(deleted.deleted_at)
class ResponseIdempotencyTests(unittest.IsolatedAsyncioTestCase):
def test_only_named_unique_constraint_is_classified_as_idempotency(self):
original = RuntimeError("unique violation")
original.constraint_name = IDEMPOTENCY_CONSTRAINT
error = IntegrityError("insert", {}, original)
self.assertEqual(
_integrity_constraint_name(error),
IDEMPOTENCY_CONSTRAINT,
)
self.assertIsNone(
_integrity_constraint_name(
IntegrityError("insert", {}, RuntimeError("other failure"))
)
)
driver_error = RuntimeError("asyncpg unique violation")
driver_error.constraint_name = IDEMPOTENCY_CONSTRAINT
adapter_error = RuntimeError("SQLAlchemy asyncpg adapter")
adapter_error.__cause__ = driver_error
self.assertEqual(
_integrity_constraint_name(
IntegrityError("insert", {}, adapter_error)
),
IDEMPOTENCY_CONSTRAINT,
)
async def test_same_request_replays_and_different_request_conflicts(self):
stored = _row("resp_idempotent")
stored.idempotency_key = "request-1"
stored.request_hash = "hash-a"
with patch(
"services.openai_responses.state.SessionLocal",
side_effect=lambda: _FakeSession({}, selected=stored),
):
replay = await get_idempotent_response(
api_key_id="key_a",
idempotency_key="request-1",
request_hash="hash-a",
)
self.assertIs(replay, stored)
with self.assertRaises(ResponsesAPIError) as raised:
await get_idempotent_response(
api_key_id="key_a",
idempotency_key="request-1",
request_hash="hash-b",
)
self.assertEqual(raised.exception.code, "idempotency_key_conflict")
self.assertEqual(raised.exception.status_code, 409)
if __name__ == "__main__":
unittest.main()
+191 -1
View File
@@ -12,7 +12,9 @@ from pipecat.frames.frames import (
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMTextFrame,
MetricsFrame,
)
from pipecat.metrics.metrics import LLMTokenUsage, LLMUsageMetricsData
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from services.brains.base import BaseBrain, BrainSpec
from services.openai_responses.protocol import ResponsesAPIError, parse_request
@@ -20,8 +22,13 @@ from services.openai_responses.runner import (
ResponsesClientToolPort,
ResponsesTextRunner,
_context_snapshot,
_usage_payload,
)
from services.openai_responses.state import (
_client_tool_results,
_validate_tool_outputs,
items_to_messages,
)
from services.openai_responses.state import _validate_tool_outputs, items_to_messages
from routes.openai_responses import _stream_response
@@ -103,6 +110,23 @@ class ResponsesProtocolTests(unittest.TestCase):
)
self.assertEqual(raised.exception.code, "invalid_request_error")
with self.assertRaises(ResponsesAPIError) as raised:
parse_request(
{
"model": "assistant:asst_1",
"input": [
{
"type": "message",
"id": "caller_owned_id",
"role": "user",
"content": "hello",
}
],
}
)
self.assertEqual(raised.exception.code, "unsupported_parameter")
self.assertEqual(raised.exception.param, "input[0].id")
class ResponsesStateTests(unittest.TestCase):
def test_context_items_become_llm_messages(self):
@@ -160,6 +184,36 @@ class ResponsesStateTests(unittest.TestCase):
)
self.assertEqual(raised.exception.code, "invalid_tool_output")
def test_provider_usage_is_combined_without_estimation(self):
usage = _usage_payload(
[
LLMTokenUsage(
prompt_tokens=10,
completion_tokens=4,
total_tokens=14,
cache_read_input_tokens=3,
reasoning_tokens=2,
),
LLMTokenUsage(
prompt_tokens=5,
completion_tokens=2,
total_tokens=7,
),
]
)
self.assertEqual(
usage,
{
"input_tokens": 15,
"input_tokens_details": {"cached_tokens": 3},
"output_tokens": 6,
"output_tokens_details": {"reasoning_tokens": 2},
"total_tokens": 21,
},
)
self.assertIsNone(_usage_payload([]))
def test_context_snapshot_aligns_provider_and_public_call_ids(self):
context = SimpleNamespace(
get_messages=lambda: [
@@ -188,6 +242,53 @@ class ResponsesStateTests(unittest.TestCase):
self.assertEqual(snapshot[0]["tool_calls"][0]["id"], "call_public")
def test_context_snapshot_aligns_repeated_tool_names_by_arguments(self):
context = SimpleNamespace(
get_messages=lambda: [
{
"role": "assistant",
"tool_calls": [
{
"id": "provider_a",
"type": "function",
"function": {
"name": "lookup_order",
"arguments": '{"id":"A"}',
},
},
{
"id": "provider_b",
"type": "function",
"function": {
"name": "lookup_order",
"arguments": '{"id":"B"}',
},
},
],
}
]
)
snapshot = _context_snapshot(
context,
function_calls=[
{
"call_id": "call_a",
"name": "lookup_order",
"arguments": '{"id": "A"}',
},
{
"call_id": "call_b",
"name": "lookup_order",
"arguments": '{"id": "B"}',
},
],
)
self.assertEqual(
[call["id"] for call in snapshot[0]["tool_calls"]],
["call_a", "call_b"],
)
def test_tool_output_cannot_be_replayed(self):
resolved = SimpleNamespace(
output_items=[{"type": "function_call", "call_id": "call_1"}],
@@ -212,6 +313,49 @@ class ResponsesStateTests(unittest.TestCase):
)
self.assertEqual(raised.exception.code, "invalid_tool_output")
def test_tool_results_keep_original_call_order(self):
parent = SimpleNamespace(
output_items=[
{
"type": "function_call",
"call_id": "call_a",
"name": "lookup_order",
"arguments": '{"id":"A"}',
},
{
"type": "function_call",
"call_id": "call_b",
"name": "lookup_order",
"arguments": '{"id":"B"}',
},
],
input_items=[],
)
results = _client_tool_results(
[parent],
[
{
"type": "function_call_output",
"call_id": "call_b",
"output": '{"status":"ok","data":"B"}',
},
{
"type": "function_call_output",
"call_id": "call_a",
"output": '{"status":"ok","data":"A"}',
},
],
)
self.assertEqual(
[result["call_id"] for result in results],
["call_a", "call_b"],
)
self.assertEqual(
[result["arguments"] for result in results],
['{"id":"A"}', '{"id":"B"}'],
)
class ResponsesClientToolTests(unittest.IsolatedAsyncioTestCase):
async def test_client_tool_is_exposed_without_waiting_for_browser(self):
@@ -247,6 +391,36 @@ class ResponsesClientToolTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result["data"]["action"], "confirmed")
self.assertFalse(port.called.is_set())
async def test_same_name_results_are_matched_by_arguments(self):
port = ResponsesClientToolPort(
[
{
"name": "lookup_order",
"arguments": '{"id":"A"}',
"result": {"status": "ok", "data": "A"},
},
{
"name": "lookup_order",
"arguments": '{"id":"B"}',
"result": {"status": "ok", "data": "B"},
},
]
)
second = await port.call(
"lookup_order",
{"id": "B"},
timeout_seconds=3,
)
first = await port.call(
"lookup_order",
{"id": "A"},
timeout_seconds=3,
)
self.assertEqual(second["data"], "B")
self.assertEqual(first["data"], "A")
class _FakeTextLLM(FrameProcessor):
async def process_frame(self, frame, direction: FrameDirection):
@@ -256,6 +430,21 @@ class _FakeTextLLM(FrameProcessor):
return
await self.push_frame(LLMFullResponseStartFrame())
await self.push_frame(LLMTextFrame("状态已恢复"))
await self.push_frame(
MetricsFrame(
data=[
LLMUsageMetricsData(
processor="fake-llm",
model="fake-model",
value=LLMTokenUsage(
prompt_tokens=8,
completion_tokens=3,
total_tokens=11,
),
)
]
)
)
await self.push_frame(LLMFullResponseEndFrame())
@@ -297,6 +486,7 @@ class ResponsesTextRunnerTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result.text, "状态已恢复")
self.assertEqual(deltas, ["状态已恢复"])
self.assertEqual(result.usage["total_tokens"], 11)
class ResponsesStreamingTests(unittest.IsolatedAsyncioTestCase):
+449
View File
@@ -0,0 +1,449 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
import httpx
from openai import AsyncOpenAI, BadRequestError, ConflictError, NotFoundError
from app import app
from routes import openai_models, openai_responses
from services.openai_realtime.auth import RealtimeAuthError, RealtimeCredential
from services.openai_responses.protocol import ResponsesAPIError
class OpenAIPythonSDKContractTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.rows: dict[str, SimpleNamespace] = {}
self.run_count = 0
self._patches = [
patch.object(
openai_responses,
"_credential",
new=AsyncMock(side_effect=self._credential),
),
patch.object(
openai_responses,
"prepare_response",
new=AsyncMock(side_effect=self._prepare_response),
),
patch.object(
openai_responses,
"_run_prepared",
new=AsyncMock(side_effect=self._run_prepared),
),
patch.object(
openai_responses,
"get_response",
new=AsyncMock(side_effect=self._get_response),
),
patch.object(
openai_responses,
"get_idempotent_response",
new=AsyncMock(side_effect=self._get_idempotent_response),
),
patch.object(
openai_responses,
"delete_response",
new=AsyncMock(side_effect=self._delete_response),
),
patch.object(
openai_models,
"_authenticate",
new=AsyncMock(side_effect=self._model_authenticate),
),
patch.object(
openai_models,
"SessionLocal",
side_effect=lambda: self._model_session(),
),
]
for patcher in self._patches:
patcher.start()
self.http_client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://contract.test",
)
self.client = AsyncOpenAI(
api_key="sk-rt-key-a",
base_url="http://contract.test/v1",
http_client=self.http_client,
max_retries=0,
)
def _model_session(self):
assistant = SimpleNamespace(
id="asst_contract",
name="Contract assistant",
type="prompt",
runtime_mode="pipeline",
created_at=datetime.now(UTC),
)
class Scalars:
@staticmethod
def all():
return [assistant]
class Result:
@staticmethod
def scalars():
return Scalars()
class Session:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def execute(self, _statement):
return Result()
return Session()
async def asyncTearDown(self):
await self.client.close()
for patcher in reversed(self._patches):
patcher.stop()
async def _credential(self, request):
token = request.headers["authorization"].removeprefix("Bearer ")
return RealtimeCredential(api_key_id=token.removeprefix("sk-rt-"))
async def _model_authenticate(self, request):
if "authorization" not in request.headers:
raise RealtimeAuthError("Missing bearer token")
async def _prepare_response(
self,
request,
*,
api_key_id,
idempotency_key=None,
request_hash=None,
):
parent = self.rows.get(request.previous_response_id)
assistant_id = (
parent.assistant_id
if parent is not None
else str(request.model).removeprefix("assistant:")
)
index = len(self.rows) + 1
row = SimpleNamespace(
id=f"resp_contract_{index}",
api_key_id=api_key_id,
assistant_id=assistant_id,
conversation_id=(
parent.conversation_id if parent else f"conv_contract_{index}"
),
previous_response_id=request.previous_response_id,
status="in_progress",
error=None,
usage=None,
input_items=request.input_items,
output_items=[],
created_at=datetime.now(UTC),
completed_at=None,
deleted_at=None,
idempotency_key=idempotency_key,
request_hash=request_hash,
)
self.rows[row.id] = row
return SimpleNamespace(row=row)
async def _run_prepared(
self,
prepared,
*,
on_delta=None,
text_item_id,
):
self.run_count += 1
row = prepared.row
input_text = " ".join(
str(part.get("text") or "")
for item in row.input_items
if item.get("type") == "message"
for part in item.get("content") or []
)
if input_text == "调用客户端工具":
row.output_items = [
{
"id": "fc_contract",
"type": "function_call",
"status": "completed",
"call_id": "call_contract",
"name": "show_message",
"arguments": '{"message":"请确认"}',
}
]
else:
text = f"第 {self.run_count} 轮回复"
if on_delta is not None:
await on_delta(text[:3])
await on_delta(text[3:])
row.output_items = [
{
"id": text_item_id,
"type": "message",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": text,
"annotations": [],
}
],
}
]
row.status = "completed"
row.completed_at = datetime.now(UTC)
return row
async def _get_response(self, response_id, *, api_key_id, **_kwargs):
row = self.rows.get(response_id)
if (
row is None
or row.api_key_id != api_key_id
or row.deleted_at is not None
):
raise ResponsesAPIError(
"Response not found",
code="response_not_found",
status_code=404,
)
return row
async def _get_idempotent_response(
self,
*,
api_key_id,
idempotency_key,
request_hash,
):
if idempotency_key is None:
return None
for row in self.rows.values():
if row.api_key_id != api_key_id or row.idempotency_key != idempotency_key:
continue
if row.request_hash != request_hash:
raise ResponsesAPIError(
"Idempotency-Key was already used with a different request",
code="idempotency_key_conflict",
param="Idempotency-Key",
status_code=409,
)
return row
return None
async def _delete_response(self, response_id, *, api_key_id):
row = await self._get_response(response_id, api_key_id=api_key_id)
row.deleted_at = datetime.now(UTC)
return row
async def test_three_turn_state_retrieve_input_items_and_delete(self):
models = await self.client.models.list()
self.assertEqual(models.data[0].id, "assistant:asst_contract")
first = await self.client.responses.create(
model="assistant:asst_contract",
input="第一轮",
)
second = await self.client.responses.create(
previous_response_id=first.id,
input="第二轮",
)
third = await self.client.responses.create(
previous_response_id=second.id,
input="第三轮",
)
self.assertEqual(third.previous_response_id, second.id)
self.assertEqual(third.model, "assistant:asst_contract")
self.assertIn("第 3 轮回复", third.output_text)
retrieved = await self.client.responses.retrieve(third.id)
self.assertEqual(retrieved.id, third.id)
self.assertEqual(retrieved.output_text, third.output_text)
raw = await self.http_client.get(
f"/v1/responses/{third.id}",
headers={
"Authorization": "Bearer sk-rt-key-a",
"X-Request-ID": "req_contract_caller",
},
)
self.assertEqual(raw.headers["x-request-id"], "req_contract_caller")
page = await self.client.responses.input_items.list(third.id, order="asc")
self.assertEqual(page.data[0].type, "message")
self.assertTrue(page.data[0].id.startswith("msg_"))
deleted = await self.client.responses.delete(first.id)
# openai-python 2.x deliberately exposes this operation as returning None,
# even though the HTTP endpoint returns the standard deletion object.
self.assertIsNone(deleted)
with self.assertRaises(NotFoundError):
await self.client.responses.retrieve(first.id)
async def test_sse_events_are_parsed_by_official_sdk(self):
request = {
"model": "assistant:asst_contract",
"input": "流式输出",
"stream": True,
}
headers = {"Idempotency-Key": "stream-contract-key"}
stream = await self.client.responses.create(
**request,
extra_headers=headers,
)
events = [event async for event in stream]
replay_stream = await self.client.responses.create(
**request,
extra_headers=headers,
)
replay_events = [event async for event in replay_stream]
event_types = [event.type for event in events]
self.assertEqual(self.run_count, 1)
self.assertEqual(
replay_events[-1].response.id,
events[-1].response.id,
)
self.assertEqual(
event_types[0:2],
["response.created", "response.in_progress"],
)
self.assertIn("response.output_text.delta", event_types)
self.assertEqual(event_types[-1], "response.completed")
self.assertEqual(
[event.sequence_number for event in events],
list(range(len(events))),
)
self.assertEqual(
[event.sequence_number for event in replay_events],
list(range(len(replay_events))),
)
retrieved = await self.client.responses.retrieve(
events[-1].response.id
)
self.assertEqual(retrieved.output_text, events[-1].response.output_text)
async def test_client_tool_output_continues_with_sdk_types(self):
tool_stream = await self.client.responses.create(
model="assistant:asst_contract",
input="调用客户端工具",
stream=True,
)
events = [event async for event in tool_stream]
self.assertIn(
"response.function_call_arguments.done",
[event.type for event in events],
)
tool_response = events[-1].response
function_call = tool_response.output[0]
self.assertEqual(function_call.type, "function_call")
continued = await self.client.responses.create(
previous_response_id=tool_response.id,
input=[
{
"type": "function_call_output",
"call_id": function_call.call_id,
"output": '{"status":"ok"}',
}
],
)
self.assertEqual(continued.status, "completed")
page = await self.client.responses.input_items.list(
continued.id,
order="asc",
)
self.assertEqual(page.data[0].type, "function_call_output")
async def test_idempotency_unsupported_field_and_cross_key_hiding(self):
headers = {"Idempotency-Key": "contract-key"}
first = await self.client.responses.create(
model="assistant:asst_contract",
input="幂等请求",
extra_headers=headers,
)
replayed = await self.client.responses.create(
model="assistant:asst_contract",
input="幂等请求",
extra_headers=headers,
)
self.assertEqual(replayed.id, first.id)
self.assertEqual(self.run_count, 1)
with self.assertRaises(BadRequestError):
await self.client.responses.cancel(first.id)
malformed = await self.http_client.post(
"/v1/responses",
headers={"Authorization": "Bearer sk-rt-key-a"},
json={"model": "assistant:asst_contract"},
)
self.assertEqual(malformed.status_code, 422)
self.assertEqual(malformed.json()["error"]["type"], "invalid_request_error")
self.assertTrue(malformed.headers["x-request-id"].startswith("req_"))
caller_owned_item_id = await self.http_client.post(
"/v1/responses",
headers={"Authorization": "Bearer sk-rt-key-a"},
json={
"model": "assistant:asst_contract",
"input": [
{
"type": "message",
"id": "caller_owned_id",
"role": "user",
"content": "hello",
}
],
},
)
self.assertEqual(caller_owned_item_id.status_code, 422)
unauthenticated = await self.http_client.get("/v1/models")
self.assertEqual(unauthenticated.status_code, 401)
self.assertEqual(
unauthenticated.json()["error"]["code"],
"invalid_api_key",
)
with self.assertRaises(ConflictError):
await self.client.responses.create(
model="assistant:asst_contract",
input="不同请求",
extra_headers=headers,
)
with self.assertRaises(BadRequestError):
await self.client.responses.create(
model="assistant:asst_contract",
input="不支持",
extra_body={"background": True},
)
other_http_client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://contract.test",
)
other = AsyncOpenAI(
api_key="sk-rt-key-b",
base_url="http://contract.test/v1",
http_client=other_http_client,
max_retries=0,
)
try:
with self.assertRaises(NotFoundError):
await other.responses.retrieve(first.id)
finally:
await other.close()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,186 @@
from __future__ import annotations
import json
import unittest
from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
from scripts.export_contracts import CONTRACT_DIRECTORY, contract_documents
from services.openai_realtime.bridge import OpenAIRealtimeBridge
from services.openai_realtime.events import (
RealtimeEventError,
error_event,
require_event,
)
from services.openai_realtime.schemas import (
EXTENSION_SERVER_EVENT_TYPES,
SERVER_EVENT_TYPES,
RealtimeServerEvent,
)
class PublicContractSnapshotTests(unittest.TestCase):
def test_generated_contracts_match_committed_snapshots(self):
for name, generated in contract_documents().items():
path = CONTRACT_DIRECTORY / name
self.assertTrue(path.exists(), f"Missing contract snapshot: {path}")
committed = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(committed, generated, f"Regenerate {name}")
def test_openapi_declares_every_public_http_media_type(self):
openapi = contract_documents()["openapi.json"]
paths = openapi["paths"]
self.assertIn("application/json", paths["/v1/responses"]["post"]["requestBody"]["content"])
response_content = paths["/v1/responses"]["post"]["responses"]["200"]["content"]
self.assertIn("application/json", response_content)
self.assertIn("text/event-stream", response_content)
call_content = paths["/v1/realtime/calls"]["post"]["requestBody"]["content"]
self.assertEqual(
set(call_content),
{"multipart/form-data", "application/sdp"},
)
self.assertIn(
"application/sdp",
paths["/v1/realtime/calls"]["post"]["responses"]["200"]["content"],
)
public_operations = [
operation
for path, methods in paths.items()
if path.startswith("/v1/")
for method, operation in methods.items()
if method in {"get", "post", "delete"}
]
for operation in public_operations:
self.assertIn("401", operation["responses"])
self.assertIn("application/json", operation["responses"]["401"]["content"])
self.assertIn("security", operation)
for response in operation["responses"].values():
self.assertIn("X-Request-ID", response.get("headers", {}))
class RealtimeEventContractTests(unittest.TestCase):
def test_client_schema_validates_supported_event_and_links_errors(self):
schema = contract_documents()[
"openai-realtime-client-events.schema.json"
]
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema)
event = {
"type": "conversation.item.create",
"event_id": "evt_123",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "你好"}],
},
}
validator.validate(event)
event = require_event(event)
self.assertEqual(event["event_id"], "evt_123")
invalid_event = {
"type": "input_audio_buffer.append",
"event_id": "evt_bad",
}
with self.assertRaises(JSONSchemaValidationError):
validator.validate(invalid_event)
with self.assertRaises(RealtimeEventError) as raised:
require_event(invalid_event)
wire_error = error_event(raised.exception)
self.assertEqual(wire_error["error"]["event_id"], "evt_bad")
extension_event = {
"type": "x.interactive_media.capabilities.update",
"event_id": "evt_extension",
"version": "1",
"capabilities": ["workflow_events"],
}
validator.validate(extension_event)
require_event(extension_event)
extension_without_version = {
key: value
for key, value in extension_event.items()
if key != "version"
}
with self.assertRaises(JSONSchemaValidationError):
validator.validate(extension_without_version)
with self.assertRaises(RealtimeEventError):
require_event(extension_without_version)
def test_server_schema_enumerates_every_emitted_event_name(self):
schema = contract_documents()[
"openai-realtime-server-events.schema.json"
]
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema)
for event_type in SERVER_EVENT_TYPES:
event = {"type": event_type, "event_id": "event_contract"}
if event_type in EXTENSION_SERVER_EVENT_TYPES:
event["version"] = "1"
validator.validate(event)
parsed = RealtimeServerEvent.model_validate(
event
)
self.assertEqual(parsed.type, event_type)
with self.assertRaises(JSONSchemaValidationError):
validator.validate(
{
"type": "x.interactive_media.workflow.event",
"event_id": "event_without_version",
}
)
def test_text_response_events_follow_realtime_order(self):
class FakeSession:
output_is_audio = False
active_response_id: str | None = None
active_output_item_id: str | None = None
response_text = ""
def begin_response(self):
self.active_response_id = self.active_response_id or "resp_1"
self.active_output_item_id = self.active_output_item_id or "item_1"
return self.active_response_id, self.active_output_item_id
def finish_response(self):
result = (
self.active_response_id,
self.active_output_item_id,
self.response_text,
)
self.active_response_id = None
self.active_output_item_id = None
self.response_text = ""
return result
bridge = OpenAIRealtimeBridge(FakeSession(), channel="websocket")
events = [
*bridge._start_assistant_output(),
*bridge._assistant_delta("你好"),
*bridge._end_assistant_output(False),
]
event_types = [event["type"] for event in events]
self.assertEqual(event_types[0:3], [
"response.created",
"response.output_item.added",
"response.content_part.added",
])
self.assertIn("response.output_text.delta", event_types)
self.assertEqual(event_types[-1], "response.done")
validator = Draft202012Validator(
contract_documents()[
"openai-realtime-server-events.schema.json"
]
)
for event in events:
validator.validate(event)
RealtimeServerEvent.model_validate(event)
if __name__ == "__main__":
unittest.main()