feat: harden public OpenAI API contracts
This commit is contained in:
+83
-2
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Model discovery for assistants exposed through the public API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from db.models import Assistant
|
||||
from db.session import SessionLocal
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from services.openai_api import (
|
||||
PUBLIC_API_BEARER,
|
||||
PUBLIC_ERROR_RESPONSES,
|
||||
REQUEST_ID_RESPONSE_HEADERS,
|
||||
openai_error_response,
|
||||
)
|
||||
from services.openai_realtime.auth import (
|
||||
RealtimeAuthError,
|
||||
authenticate_bearer,
|
||||
bearer_from_authorization,
|
||||
)
|
||||
from services.openai_responses.schemas import ModelList
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
PUBLIC_ASSISTANT_TYPES = {"prompt", "workflow", "dify", "fastgpt"}
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/v1/models",
|
||||
tags=["openai-models"],
|
||||
dependencies=[Depends(PUBLIC_API_BEARER)],
|
||||
)
|
||||
|
||||
|
||||
async def _authenticate(request: Request) -> None:
|
||||
token = bearer_from_authorization(request.headers.get("authorization"))
|
||||
async with SessionLocal() as db:
|
||||
credential = await authenticate_bearer(db, token)
|
||||
if credential.ephemeral:
|
||||
raise RealtimeAuthError("A long-lived API key is required")
|
||||
|
||||
|
||||
def _capabilities(assistant: Assistant) -> list[str]:
|
||||
capabilities = ["realtime"]
|
||||
if assistant.runtime_mode == "pipeline":
|
||||
capabilities.insert(0, "responses")
|
||||
return capabilities
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=ModelList,
|
||||
responses={
|
||||
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
|
||||
**PUBLIC_ERROR_RESPONSES,
|
||||
},
|
||||
summary="List assistants available through the public API",
|
||||
)
|
||||
async def list_models(request: Request):
|
||||
try:
|
||||
await _authenticate(request)
|
||||
async with SessionLocal() as db:
|
||||
assistants = (
|
||||
await db.execute(
|
||||
select(Assistant)
|
||||
.where(
|
||||
Assistant.type.in_(PUBLIC_ASSISTANT_TYPES),
|
||||
Assistant.runtime_mode.in_(("pipeline", "realtime")),
|
||||
)
|
||||
.order_by(Assistant.created_at.asc(), Assistant.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": f"assistant:{assistant.id}",
|
||||
"object": "model",
|
||||
"created": int(assistant.created_at.timestamp()),
|
||||
"owned_by": "interactive-media",
|
||||
"name": assistant.name,
|
||||
"capabilities": _capabilities(assistant),
|
||||
"assistant_id": assistant.id,
|
||||
"assistant_type": assistant.type,
|
||||
"runtime_mode": assistant.runtime_mode,
|
||||
}
|
||||
for assistant in assistants
|
||||
],
|
||||
}
|
||||
except RealtimeAuthError as exc:
|
||||
return openai_error_response(
|
||||
str(exc),
|
||||
code="invalid_api_key",
|
||||
status_code=401,
|
||||
)
|
||||
@@ -8,9 +8,15 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from db.session import SessionLocal
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from fastapi import APIRouter, Depends, Request, WebSocket
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from loguru import logger
|
||||
from services.openai_api import (
|
||||
PUBLIC_API_BEARER,
|
||||
PUBLIC_ERROR_RESPONSES,
|
||||
REQUEST_ID_RESPONSE_HEADERS,
|
||||
openai_error_response,
|
||||
)
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from services.openai_realtime.auth import (
|
||||
@@ -27,6 +33,10 @@ from services.openai_realtime.events import (
|
||||
assistant_id_from_model,
|
||||
)
|
||||
from services.openai_realtime.session import OpenAIRealtimeSession
|
||||
from services.openai_realtime.schemas import (
|
||||
RealtimeClientSecretBody,
|
||||
RealtimeClientSecretResponse,
|
||||
)
|
||||
from services.openai_realtime.webrtc import OpenAIRealtimeWebRTCConnection
|
||||
from services.openai_realtime.websocket import build_openai_websocket_transport
|
||||
from services.pipecat.pipeline import run_pipeline
|
||||
@@ -44,17 +54,18 @@ router = APIRouter(prefix="/v1/realtime", tags=["openai-realtime"])
|
||||
_webrtc_peers: dict[str, OpenAIRealtimeWebRTCConnection] = {}
|
||||
|
||||
|
||||
def _api_error(message: str, *, code: str, status_code: int) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
def _api_error(
|
||||
message: str,
|
||||
*,
|
||||
code: str,
|
||||
status_code: int,
|
||||
param: str | None = None,
|
||||
) -> JSONResponse:
|
||||
return openai_error_response(
|
||||
message,
|
||||
code=code,
|
||||
status_code=status_code,
|
||||
content={
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"code": code,
|
||||
"message": message,
|
||||
"param": None,
|
||||
}
|
||||
},
|
||||
param=param,
|
||||
)
|
||||
|
||||
|
||||
@@ -124,14 +135,25 @@ async def _build_session(
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/client_secrets")
|
||||
async def create_realtime_client_secret(request: Request):
|
||||
@router.post(
|
||||
"/client_secrets",
|
||||
response_model=RealtimeClientSecretResponse,
|
||||
responses={
|
||||
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
|
||||
**PUBLIC_ERROR_RESPONSES,
|
||||
},
|
||||
dependencies=[Depends(PUBLIC_API_BEARER)],
|
||||
summary="Create a short-lived Realtime client secret",
|
||||
)
|
||||
async def create_realtime_client_secret(
|
||||
body: RealtimeClientSecretBody,
|
||||
request: Request,
|
||||
):
|
||||
try:
|
||||
credential = await _credential(request)
|
||||
if credential.ephemeral:
|
||||
raise RealtimeAuthError("A long-lived API key is required")
|
||||
body = await request.json()
|
||||
session = _session_payload(body)
|
||||
session = _session_payload(body.model_dump(exclude_unset=True))
|
||||
_reject_locked_creation_fields(session)
|
||||
assistant_id = assistant_id_from_model(session.get("model"))
|
||||
# Resolve now so invalid models/configurations fail before a browser gets a token.
|
||||
@@ -155,13 +177,62 @@ async def create_realtime_client_secret(request: Request):
|
||||
except RealtimeAuthError as exc:
|
||||
return _api_error(str(exc), code="invalid_api_key", status_code=401)
|
||||
except RealtimeEventError as exc:
|
||||
return _api_error(str(exc), code=exc.code, status_code=400)
|
||||
return _api_error(
|
||||
str(exc), code=exc.code, status_code=400, param=exc.param
|
||||
)
|
||||
except ValueError as exc:
|
||||
code = "invalid_model" if "助手不存在" in str(exc) else "invalid_session"
|
||||
return _api_error(str(exc), code=code, status_code=400)
|
||||
|
||||
|
||||
@router.post("/calls")
|
||||
@router.post(
|
||||
"/calls",
|
||||
response_class=Response,
|
||||
responses={
|
||||
200: {
|
||||
"description": "WebRTC SDP answer.",
|
||||
"headers": {
|
||||
**REQUEST_ID_RESPONSE_HEADERS,
|
||||
"Location": {
|
||||
"description": "Relative URL of the created Realtime call.",
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"content": {
|
||||
"application/sdp": {
|
||||
"schema": {"type": "string", "format": "sdp"},
|
||||
"example": "v=0\\r\\no=- ...",
|
||||
}
|
||||
},
|
||||
},
|
||||
**PUBLIC_ERROR_RESPONSES,
|
||||
},
|
||||
dependencies=[Depends(PUBLIC_API_BEARER)],
|
||||
openapi_extra={
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["sdp", "session"],
|
||||
"properties": {
|
||||
"sdp": {"type": "string", "format": "sdp"},
|
||||
"session": {
|
||||
"type": "string",
|
||||
"description": "JSON-encoded Realtime session.",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"application/sdp": {
|
||||
"schema": {"type": "string", "format": "sdp"}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
summary="Create a Realtime WebRTC call",
|
||||
)
|
||||
async def create_realtime_call(request: Request):
|
||||
try:
|
||||
credential = await _credential(request)
|
||||
@@ -206,7 +277,8 @@ async def create_realtime_call(request: Request):
|
||||
return _api_error(str(exc), code="invalid_api_key", status_code=401)
|
||||
except (RealtimeEventError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
code = exc.code if isinstance(exc, RealtimeEventError) else "invalid_request_error"
|
||||
return _api_error(str(exc), code=code, status_code=400)
|
||||
param = exc.param if isinstance(exc, RealtimeEventError) else None
|
||||
return _api_error(str(exc), code=code, status_code=400, param=param)
|
||||
except ValueError as exc:
|
||||
code = "invalid_model" if "助手不存在" in str(exc) else "invalid_session"
|
||||
return _api_error(str(exc), code=code, status_code=400)
|
||||
|
||||
+330
-158
@@ -3,15 +3,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import settings
|
||||
from db.session import SessionLocal
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from loguru import logger
|
||||
from services.openai_api import (
|
||||
DeletedResource,
|
||||
OpenAIErrorResponse,
|
||||
PUBLIC_API_BEARER,
|
||||
PUBLIC_ERROR_RESPONSES,
|
||||
REQUEST_ID_RESPONSE_HEADERS,
|
||||
openai_error_response,
|
||||
)
|
||||
from services.openai_realtime.auth import (
|
||||
RealtimeAuthError,
|
||||
RealtimeCredential,
|
||||
@@ -21,21 +28,40 @@ from services.openai_realtime.auth import (
|
||||
from services.openai_responses.protocol import (
|
||||
ResponseRequest,
|
||||
ResponsesAPIError,
|
||||
error_payload,
|
||||
new_id,
|
||||
normalize_idempotency_key,
|
||||
parse_request,
|
||||
request_fingerprint,
|
||||
response_payload,
|
||||
)
|
||||
from services.openai_responses.runner import ResponseRunError, ResponsesTextRunner
|
||||
from services.openai_responses.schemas import (
|
||||
ResponseCreateRequest,
|
||||
ResponseInputItemList,
|
||||
ResponseObject,
|
||||
)
|
||||
from services.openai_responses.state import (
|
||||
PreparedResponse,
|
||||
delete_response,
|
||||
finish_response,
|
||||
get_idempotent_response,
|
||||
get_response,
|
||||
prepare_response,
|
||||
)
|
||||
from services.openai_responses.wire import (
|
||||
SSEEventSequence,
|
||||
assistant_text_item,
|
||||
build_output_items,
|
||||
output_lifecycle_events,
|
||||
stored_input_items,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/responses", tags=["openai-responses"])
|
||||
router = APIRouter(
|
||||
prefix="/v1/responses",
|
||||
tags=["openai-responses"],
|
||||
dependencies=[Depends(PUBLIC_API_BEARER)],
|
||||
)
|
||||
|
||||
|
||||
async def _credential(request: Request) -> RealtimeCredential:
|
||||
@@ -48,43 +74,12 @@ async def _credential(request: Request) -> RealtimeCredential:
|
||||
|
||||
|
||||
def _json_error(error: ResponsesAPIError) -> JSONResponse:
|
||||
return JSONResponse(status_code=error.status_code, content=error_payload(error))
|
||||
|
||||
|
||||
def _sse(event: dict[str, Any]) -> bytes:
|
||||
return (
|
||||
f"event: {event['type']}\n"
|
||||
f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _text_item(item_id: str, text: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _output_items(
|
||||
*,
|
||||
text: str,
|
||||
text_item_id: str,
|
||||
function_calls: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
if text:
|
||||
items.append(_text_item(text_item_id, text))
|
||||
items.extend(function_calls)
|
||||
return items
|
||||
return openai_error_response(
|
||||
error.message,
|
||||
code=error.code,
|
||||
param=error.param,
|
||||
status_code=error.status_code,
|
||||
)
|
||||
|
||||
|
||||
async def _run_prepared(
|
||||
@@ -106,7 +101,7 @@ async def _run_prepared(
|
||||
return await finish_response(
|
||||
prepared.row.id,
|
||||
status="completed",
|
||||
output_items=_output_items(
|
||||
output_items=build_output_items(
|
||||
text=result.text,
|
||||
text_item_id=text_item_id,
|
||||
function_calls=result.function_calls,
|
||||
@@ -114,6 +109,7 @@ async def _run_prepared(
|
||||
context_messages=result.context_messages,
|
||||
dynamic_variables=result.dynamic_variables,
|
||||
runtime_state=result.runtime_state,
|
||||
usage=result.usage,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
await finish_response(
|
||||
@@ -134,11 +130,17 @@ async def _run_prepared(
|
||||
)
|
||||
raise
|
||||
except ResponseRunError as exc:
|
||||
logger.warning(
|
||||
f"Responses pipeline failed for {prepared.row.id}: {exc}"
|
||||
)
|
||||
await finish_response(
|
||||
prepared.row.id,
|
||||
status="failed",
|
||||
output_items=[],
|
||||
error={"code": "response_failed", "message": str(exc)},
|
||||
error={
|
||||
"code": "response_failed",
|
||||
"message": "Response generation failed",
|
||||
},
|
||||
)
|
||||
raise ResponsesAPIError(
|
||||
"Response generation failed",
|
||||
@@ -164,7 +166,8 @@ async def _stream_response(
|
||||
text_item_id: str,
|
||||
) -> AsyncIterator[bytes]:
|
||||
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
|
||||
text_started = False
|
||||
started_text_items: set[str] = set()
|
||||
sequence = SSEEventSequence()
|
||||
|
||||
async def on_delta(delta: str) -> None:
|
||||
await queue.put({"kind": "delta", "delta": delta})
|
||||
@@ -185,144 +188,203 @@ async def _stream_response(
|
||||
task = asyncio.create_task(generate(), name=f"stream-{prepared.row.id}")
|
||||
try:
|
||||
created = response_payload(prepared.row)
|
||||
yield _sse({"type": "response.created", "response": created})
|
||||
yield _sse({"type": "response.in_progress", "response": created})
|
||||
yield sequence.encode("response.created", response=created)
|
||||
yield sequence.encode("response.in_progress", response=created)
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if event is None:
|
||||
break
|
||||
if event["kind"] == "delta":
|
||||
if not text_started:
|
||||
text_started = True
|
||||
pending_item = _text_item(text_item_id, "")
|
||||
if text_item_id not in started_text_items:
|
||||
started_text_items.add(text_item_id)
|
||||
pending_item = assistant_text_item(text_item_id, "")
|
||||
pending_item["status"] = "in_progress"
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": pending_item,
|
||||
}
|
||||
yield sequence.encode(
|
||||
"response.output_item.added",
|
||||
output_index=0,
|
||||
item=pending_item,
|
||||
)
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.content_part.added",
|
||||
"item_id": text_item_id,
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
"annotations": [],
|
||||
},
|
||||
}
|
||||
yield sequence.encode(
|
||||
"response.content_part.added",
|
||||
item_id=text_item_id,
|
||||
output_index=0,
|
||||
content_index=0,
|
||||
part={
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
"annotations": [],
|
||||
},
|
||||
)
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": text_item_id,
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": event["delta"],
|
||||
}
|
||||
yield sequence.encode(
|
||||
"response.output_text.delta",
|
||||
item_id=text_item_id,
|
||||
output_index=0,
|
||||
content_index=0,
|
||||
delta=event["delta"],
|
||||
logprobs=[],
|
||||
)
|
||||
continue
|
||||
if event["kind"] == "failed":
|
||||
error: ResponsesAPIError = event["error"]
|
||||
yield _sse({"type": "error", **error_payload(error)})
|
||||
yield sequence.encode(
|
||||
"error",
|
||||
code=error.code,
|
||||
message=error.message,
|
||||
param=error.param,
|
||||
)
|
||||
failed = await get_response(
|
||||
prepared.row.id, api_key_id=prepared.row.api_key_id
|
||||
)
|
||||
yield _sse(
|
||||
{"type": "response.failed", "response": response_payload(failed)}
|
||||
yield sequence.encode(
|
||||
"response.failed",
|
||||
response=response_payload(failed),
|
||||
)
|
||||
continue
|
||||
|
||||
row = event["row"]
|
||||
payload = response_payload(row)
|
||||
output_index = 0
|
||||
for item in row.output_items or []:
|
||||
if item.get("type") == "message":
|
||||
text = str((item.get("content") or [{}])[0].get("text") or "")
|
||||
if not text_started:
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {
|
||||
**item,
|
||||
"status": "in_progress",
|
||||
"content": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.content_part.added",
|
||||
"item_id": item["id"],
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
"annotations": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.output_text.done",
|
||||
"item_id": item["id"],
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.content_part.done",
|
||||
"item_id": item["id"],
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": item["content"][0],
|
||||
}
|
||||
)
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item,
|
||||
}
|
||||
)
|
||||
else:
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": item,
|
||||
}
|
||||
)
|
||||
yield _sse(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item,
|
||||
}
|
||||
)
|
||||
output_index += 1
|
||||
yield _sse({"type": "response.completed", "response": payload})
|
||||
for output_event in output_lifecycle_events(
|
||||
row,
|
||||
started_text_items=started_text_items,
|
||||
replay_text=False,
|
||||
):
|
||||
event_type = output_event.pop("type")
|
||||
yield sequence.encode(event_type, **output_event)
|
||||
yield sequence.encode("response.completed", response=payload)
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_response(request: Request):
|
||||
async def _stream_stored_response(row) -> AsyncIterator[bytes]:
|
||||
"""Replay a persisted terminal response for an idempotent streaming call."""
|
||||
|
||||
sequence = SSEEventSequence()
|
||||
final_payload = response_payload(row)
|
||||
initial_payload = {
|
||||
**final_payload,
|
||||
"status": "in_progress",
|
||||
"completed_at": None,
|
||||
"error": None,
|
||||
"output": [],
|
||||
}
|
||||
yield sequence.encode("response.created", response=initial_payload)
|
||||
yield sequence.encode("response.in_progress", response=initial_payload)
|
||||
if row.status == "completed":
|
||||
for output_event in output_lifecycle_events(
|
||||
row,
|
||||
started_text_items=set(),
|
||||
replay_text=True,
|
||||
):
|
||||
event_type = output_event.pop("type")
|
||||
yield sequence.encode(event_type, **output_event)
|
||||
yield sequence.encode("response.completed", response=final_payload)
|
||||
return
|
||||
|
||||
error = row.error or {
|
||||
"code": "response_cancelled",
|
||||
"message": "Response was cancelled",
|
||||
}
|
||||
yield sequence.encode(
|
||||
"error",
|
||||
code=str(error.get("code") or "response_failed"),
|
||||
message=str(error.get("message") or "Response generation failed"),
|
||||
param=None,
|
||||
)
|
||||
yield sequence.encode("response.failed", response=final_payload)
|
||||
|
||||
|
||||
def _idempotent_error(row) -> JSONResponse | None:
|
||||
if row.status == "completed":
|
||||
return None
|
||||
error = row.error or {}
|
||||
code = str(error.get("code") or "response_failed")
|
||||
status_code = 504 if code == "response_timeout" else 500
|
||||
if row.status == "cancelled":
|
||||
code = str(error.get("code") or "response_cancelled")
|
||||
status_code = 409
|
||||
return openai_error_response(
|
||||
str(error.get("message") or f"Response is {row.status}"),
|
||||
code=code,
|
||||
status_code=status_code,
|
||||
headers={"X-Idempotent-Replayed": "true"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ResponseObject,
|
||||
responses={
|
||||
200: {
|
||||
"description": "A completed Response or a stream of Responses events.",
|
||||
"headers": {
|
||||
**REQUEST_ID_RESPONSE_HEADERS,
|
||||
"X-Idempotent-Replayed": {
|
||||
"description": "Present and true when a stored result is replayed.",
|
||||
"schema": {"type": "string", "enum": ["true"]},
|
||||
},
|
||||
},
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {"type": "string"},
|
||||
"example": (
|
||||
"event: response.created\\n"
|
||||
'data: {"type":"response.created",...}\\n\\n'
|
||||
),
|
||||
}
|
||||
},
|
||||
},
|
||||
**PUBLIC_ERROR_RESPONSES,
|
||||
},
|
||||
summary="Create a stateful model response",
|
||||
)
|
||||
async def create_response(
|
||||
body: ResponseCreateRequest,
|
||||
request: Request,
|
||||
idempotency_key_header: Annotated[
|
||||
str | None,
|
||||
Header(
|
||||
alias="Idempotency-Key",
|
||||
description="Retry key scoped to the authenticated API key.",
|
||||
),
|
||||
] = None,
|
||||
):
|
||||
try:
|
||||
credential = await _credential(request)
|
||||
body = await request.json()
|
||||
parsed: ResponseRequest = parse_request(body)
|
||||
prepared = await prepare_response(parsed, api_key_id=credential.api_key_id)
|
||||
body_value = body.model_dump(exclude_unset=True)
|
||||
parsed: ResponseRequest = parse_request(body_value)
|
||||
idempotency_key = normalize_idempotency_key(idempotency_key_header)
|
||||
fingerprint = request_fingerprint(body_value)
|
||||
stored = await get_idempotent_response(
|
||||
api_key_id=credential.api_key_id,
|
||||
idempotency_key=idempotency_key,
|
||||
request_hash=fingerprint,
|
||||
)
|
||||
if stored is not None:
|
||||
if parsed.stream:
|
||||
return StreamingResponse(
|
||||
_stream_stored_response(stored),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"X-Idempotent-Replayed": "true",
|
||||
},
|
||||
)
|
||||
replay_error = _idempotent_error(stored)
|
||||
if replay_error is not None:
|
||||
return replay_error
|
||||
return JSONResponse(
|
||||
content=response_payload(stored),
|
||||
headers={"X-Idempotent-Replayed": "true"},
|
||||
)
|
||||
prepared = await prepare_response(
|
||||
parsed,
|
||||
api_key_id=credential.api_key_id,
|
||||
idempotency_key=idempotency_key,
|
||||
request_hash=fingerprint if idempotency_key else None,
|
||||
)
|
||||
text_item_id = new_id("msg")
|
||||
if parsed.stream:
|
||||
return StreamingResponse(
|
||||
@@ -336,15 +398,21 @@ async def create_response(request: Request):
|
||||
return _json_error(
|
||||
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
|
||||
)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return _json_error(ResponsesAPIError("Request body must be valid JSON"))
|
||||
except ResponsesAPIError as exc:
|
||||
return _json_error(exc)
|
||||
except ValueError as exc:
|
||||
return _json_error(ResponsesAPIError(str(exc)))
|
||||
|
||||
|
||||
@router.get("/{response_id}")
|
||||
@router.get(
|
||||
"/{response_id}",
|
||||
response_model=ResponseObject,
|
||||
responses={
|
||||
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
|
||||
**PUBLIC_ERROR_RESPONSES,
|
||||
},
|
||||
summary="Retrieve a stored response",
|
||||
)
|
||||
async def retrieve_response(response_id: str, request: Request):
|
||||
try:
|
||||
credential = await _credential(request)
|
||||
@@ -356,3 +424,107 @@ async def retrieve_response(response_id: str, request: Request):
|
||||
)
|
||||
except ResponsesAPIError as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{response_id}/input_items",
|
||||
response_model=ResponseInputItemList,
|
||||
responses={
|
||||
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
|
||||
**PUBLIC_ERROR_RESPONSES,
|
||||
},
|
||||
summary="List input items for a stored response",
|
||||
)
|
||||
async def list_response_input_items(
|
||||
response_id: str,
|
||||
request: Request,
|
||||
after: Annotated[str | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
order: Annotated[Literal["asc", "desc"], Query()] = "desc",
|
||||
include: Annotated[list[str] | None, Query()] = None,
|
||||
):
|
||||
try:
|
||||
credential = await _credential(request)
|
||||
if include:
|
||||
raise ResponsesAPIError(
|
||||
"include is not supported by this API subset",
|
||||
code="unsupported_parameter",
|
||||
param="include",
|
||||
)
|
||||
row = await get_response(response_id, api_key_id=credential.api_key_id)
|
||||
items = stored_input_items(row)
|
||||
ordered = items if order == "asc" else list(reversed(items))
|
||||
start = 0
|
||||
if after is not None:
|
||||
try:
|
||||
start = next(
|
||||
index + 1
|
||||
for index, item in enumerate(ordered)
|
||||
if item["id"] == after
|
||||
)
|
||||
except StopIteration as exc:
|
||||
raise ResponsesAPIError(
|
||||
"after does not identify an input item in this response",
|
||||
code="invalid_cursor",
|
||||
param="after",
|
||||
) from exc
|
||||
data = ordered[start : start + limit]
|
||||
return {
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"first_id": data[0]["id"] if data else None,
|
||||
"last_id": data[-1]["id"] if data else None,
|
||||
"has_more": start + len(data) < len(ordered),
|
||||
}
|
||||
except RealtimeAuthError as exc:
|
||||
return _json_error(
|
||||
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
|
||||
)
|
||||
except ResponsesAPIError as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{response_id}",
|
||||
response_model=DeletedResource,
|
||||
responses={
|
||||
200: {"headers": REQUEST_ID_RESPONSE_HEADERS},
|
||||
**PUBLIC_ERROR_RESPONSES,
|
||||
},
|
||||
summary="Delete a stored response",
|
||||
)
|
||||
async def remove_response(response_id: str, request: Request):
|
||||
try:
|
||||
credential = await _credential(request)
|
||||
await delete_response(response_id, api_key_id=credential.api_key_id)
|
||||
return {"id": response_id, "object": "response", "deleted": True}
|
||||
except RealtimeAuthError as exc:
|
||||
return _json_error(
|
||||
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
|
||||
)
|
||||
except ResponsesAPIError as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{response_id}/cancel",
|
||||
response_model=OpenAIErrorResponse,
|
||||
status_code=400,
|
||||
responses=PUBLIC_ERROR_RESPONSES,
|
||||
summary="Reject background cancellation in subset v1",
|
||||
)
|
||||
async def reject_response_cancellation(response_id: str, request: Request):
|
||||
del response_id
|
||||
try:
|
||||
await _credential(request)
|
||||
raise ResponsesAPIError(
|
||||
"Response cancellation requires background mode, which is not supported",
|
||||
code="unsupported_operation",
|
||||
param="background",
|
||||
)
|
||||
except RealtimeAuthError as exc:
|
||||
return _json_error(
|
||||
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
|
||||
)
|
||||
except ResponsesAPIError as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user