From 2649d64b55853b72dc794f2ed2ec19429981b1d8 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Mon, 7 Sep 2026 14:25:48 +0800 Subject: [PATCH] feat: harden public OpenAI API contracts --- .github/workflows/ci.yml | 69 + Makefile | 18 +- backend/app.py | 85 +- backend/contracts/README.md | 11 + .../openai-realtime-client-events.schema.json | 452 + .../openai-realtime-server-events.schema.json | 91 + backend/contracts/openapi.json | 11823 ++++++++++++++++ backend/db/models.py | 15 + ...7_0025_harden_openai_responses_contract.py | 64 + backend/requirements.txt | 2 + backend/routes/openai_models.py | 92 + backend/routes/openai_realtime.py | 108 +- backend/routes/openai_responses.py | 488 +- backend/scripts/export_contracts.py | 51 + backend/scripts/run_tests.py | 18 + backend/services/openai_api.py | 195 + backend/services/openai_realtime/bridge.py | 34 +- backend/services/openai_realtime/events.py | 19 +- backend/services/openai_realtime/schemas.py | 236 + backend/services/openai_responses/protocol.py | 97 +- backend/services/openai_responses/runner.py | 94 +- backend/services/openai_responses/schemas.py | 314 + backend/services/openai_responses/state.py | 167 +- backend/services/openai_responses/wire.py | 193 + .../test_openai_response_state_contract.py | 172 + backend/tests/test_openai_responses.py | 192 +- backend/tests/test_openai_sdk_contract.py | 449 + .../tests/test_public_contract_snapshots.py | 186 + docs-developer/development-plan.mdx | 361 + docs-developer/docs.json | 6 +- docs-developer/openai-compatibility.mdx | 149 + docs-developer/realtime-protocol.mdx | 26 +- docs-developer/responses-api.mdx | 131 +- frontend/package-lock.json | 39 + frontend/package.json | 4 +- .../tests/openai-responses-contract.test.mjs | 428 + 36 files changed, 16643 insertions(+), 236 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/contracts/README.md create mode 100644 backend/contracts/openai-realtime-client-events.schema.json create mode 100644 backend/contracts/openai-realtime-server-events.schema.json create mode 100644 backend/contracts/openapi.json create mode 100644 backend/migrations/versions/20260907_0025_harden_openai_responses_contract.py create mode 100644 backend/routes/openai_models.py create mode 100644 backend/scripts/export_contracts.py create mode 100644 backend/scripts/run_tests.py create mode 100644 backend/services/openai_api.py create mode 100644 backend/services/openai_realtime/schemas.py create mode 100644 backend/services/openai_responses/schemas.py create mode 100644 backend/services/openai_responses/wire.py create mode 100644 backend/tests/test_openai_response_state_contract.py create mode 100644 backend/tests/test_openai_sdk_contract.py create mode 100644 backend/tests/test_public_contract_snapshots.py create mode 100644 docs-developer/development-plan.mdx create mode 100644 docs-developer/openai-compatibility.mdx create mode 100644 frontend/tests/openai-responses-contract.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..159810d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: Quality gate + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@127.0.0.1:5432/postgres + NLTK_DISABLE_IMPORT_SECURITY: "1" + AUTH_SECRET_KEY: ci-admin-secret + REALTIME_TOKEN_SECRET: ci-realtime-secret + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: backend/requirements.txt + - name: Install backend dependencies + run: python -m pip install -r backend/requirements.txt + - name: Run migrations from an empty database + working-directory: backend + run: alembic upgrade head + - name: Run backend and contract tests + working-directory: backend + run: python -m scripts.run_tests + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + - name: Run JavaScript SDK contract tests + working-directory: frontend + run: npm run test:contracts + - name: Lint frontend + working-directory: frontend + run: npm run lint + - name: Build production frontend + working-directory: frontend + run: npm run build diff --git a/Makefile b/Makefile index ddd79de..af935dc 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,10 @@ # 容器内执行 psql 的固定前缀(postgres/postgres/postgres,见 docker-compose.yaml) PSQL = docker compose exec -T postgres psql -U postgres -d postgres UV = UV_CACHE_DIR=/private/tmp/ai-video-uv-cache uv run --with-requirements requirements.txt +BACKEND_PYTHON = $(if $(wildcard backend/.venv/bin/python),.venv/bin/python,$(UV) python) .DEFAULT_GOAL := help -.PHONY: help up down restart logs api-logs db db-list db-up db-migrate db-revision db-current db-sync-interface-definitions db-init db-seed db-seed-model-resources db-seed-assistants db-clear db-reset db-drop-schema db-reset-hard +.PHONY: help up down restart logs api-logs test test-backend test-frontend contract-snapshots contract-check db db-list db-up db-migrate db-revision db-current db-sync-interface-definitions db-init db-seed db-seed-model-resources db-seed-assistants db-clear db-reset db-drop-schema db-reset-hard help: ## 列出所有可用目标 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ @@ -30,6 +31,21 @@ logs: ## 跟随全部服务日志 api-logs: ## 只看后端日志 docker compose logs -f api +# ---- 质量与契约 ---- +test: test-backend test-frontend contract-check ## 运行本地提交前质量门禁 + +test-backend: ## 运行全部后端测试(已内置 NLTK 本地环境兼容项) + cd backend && $(BACKEND_PYTHON) -m scripts.run_tests + +test-frontend: ## 运行前端 lint、契约测试和 production build + cd frontend && npm run lint && npm run test:contracts && npm run build + +contract-snapshots: ## 重新生成 OpenAPI 与 Realtime JSON Schema 快照 + cd backend && $(BACKEND_PYTHON) -m scripts.export_contracts + +contract-check: ## 检查已提交契约快照是否与运行时代码一致 + cd backend && $(BACKEND_PYTHON) -m unittest tests.test_public_contract_snapshots + # ---- 数据库 ---- db: ## 进入交互式 psql docker compose exec postgres psql -U postgres -d postgres diff --git a/backend/app.py b/backend/app.py index a57614f..9764444 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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) diff --git a/backend/contracts/README.md b/backend/contracts/README.md new file mode 100644 index 0000000..494bade --- /dev/null +++ b/backend/contracts/README.md @@ -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. diff --git a/backend/contracts/openai-realtime-client-events.schema.json b/backend/contracts/openai-realtime-client-events.schema.json new file mode 100644 index 0000000..38cba4b --- /dev/null +++ b/backend/contracts/openai-realtime-client-events.schema.json @@ -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" +} diff --git a/backend/contracts/openai-realtime-server-events.schema.json b/backend/contracts/openai-realtime-server-events.schema.json new file mode 100644 index 0000000..2b4dfbe --- /dev/null +++ b/backend/contracts/openai-realtime-server-events.schema.json @@ -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" +} diff --git a/backend/contracts/openapi.json b/backend/contracts/openapi.json new file mode 100644 index 0000000..46407e8 --- /dev/null +++ b/backend/contracts/openapi.json @@ -0,0 +1,11823 @@ +{ + "components": { + "schemas": { + "AdminUserOut": { + "properties": { + "displayName": { + "title": "Displayname", + "type": "string" + }, + "role": { + "title": "Role", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "displayName", + "role" + ], + "title": "AdminUserOut", + "type": "object" + }, + "AnalysisConfig": { + "properties": { + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "fields": { + "items": { + "$ref": "#/components/schemas/AnalysisField" + }, + "maxItems": 20, + "title": "Fields", + "type": "array" + }, + "modelResourceId": { + "default": "", + "title": "Modelresourceid", + "type": "string" + } + }, + "title": "AnalysisConfig", + "type": "object" + }, + "AnalysisField": { + "properties": { + "description": { + "default": "", + "maxLength": 500, + "title": "Description", + "type": "string" + }, + "enumValues": { + "items": { + "type": "string" + }, + "maxItems": 30, + "title": "Enumvalues", + "type": "array" + }, + "id": { + "maxLength": 64, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "name": { + "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$", + "title": "Name", + "type": "string" + }, + "type": { + "default": "string", + "enum": [ + "string", + "boolean", + "integer", + "number", + "enum" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "title": "AnalysisField", + "type": "object" + }, + "AnalysisWebhookSettingOut": { + "properties": { + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "secretConfigured": { + "default": false, + "title": "Secretconfigured", + "type": "boolean" + }, + "updatedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + }, + "url": { + "default": "", + "title": "Url", + "type": "string" + } + }, + "title": "AnalysisWebhookSettingOut", + "type": "object" + }, + "AnalysisWebhookSettingUpdate": { + "properties": { + "clearSecret": { + "default": false, + "title": "Clearsecret", + "type": "boolean" + }, + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "secret": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Secret" + }, + "url": { + "default": "", + "maxLength": 2048, + "title": "Url", + "type": "string" + } + }, + "title": "AnalysisWebhookSettingUpdate", + "type": "object" + }, + "AnalysisWebhookTestIn": { + "properties": { + "secret": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Secret" + }, + "url": { + "maxLength": 2048, + "minLength": 1, + "title": "Url", + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "AnalysisWebhookTestIn", + "type": "object" + }, + "AnalysisWebhookTestOut": { + "properties": { + "latencyMs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Latencyms" + }, + "message": { + "title": "Message", + "type": "string" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + }, + "statusCode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Statuscode" + } + }, + "required": [ + "ok", + "message", + "payload" + ], + "title": "AnalysisWebhookTestOut", + "type": "object" + }, + "AnyToolSimulationScope": { + "properties": { + "phase": { + "const": "any", + "default": "any", + "title": "Phase", + "type": "string" + } + }, + "title": "AnyToolSimulationScope", + "type": "object" + }, + "AssistantOut": { + "properties": { + "analysisConfig": { + "$ref": "#/components/schemas/AnalysisConfig" + }, + "apiKey": { + "default": "", + "title": "Apikey", + "type": "string" + }, + "apiUrl": { + "default": "", + "title": "Apiurl", + "type": "string" + }, + "appId": { + "default": "", + "title": "Appid", + "type": "string" + }, + "builtInTools": { + "$ref": "#/components/schemas/BuiltInTools-Output" + }, + "dynamicVariableDefinitions": { + "additionalProperties": { + "$ref": "#/components/schemas/DynamicVariableDefinition" + }, + "title": "Dynamicvariabledefinitions", + "type": "object" + }, + "enableInterrupt": { + "default": true, + "title": "Enableinterrupt", + "type": "boolean" + }, + "graph": { + "additionalProperties": true, + "title": "Graph", + "type": "object" + }, + "greeting": { + "default": "", + "title": "Greeting", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "knowledgeBaseId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Knowledgebaseid" + }, + "knowledgeRetrievalConfig": { + "$ref": "#/components/schemas/KnowledgeRetrievalConfig" + }, + "modelResourceIds": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "enum": [ + "LLM", + "ASR", + "TTS", + "Realtime", + "Embedding", + "Agent" + ] + }, + "title": "Modelresourceids", + "type": "object" + }, + "name": { + "title": "Name", + "type": "string" + }, + "prompt": { + "default": "", + "title": "Prompt", + "type": "string" + }, + "runtimeMode": { + "default": "pipeline", + "enum": [ + "pipeline", + "realtime" + ], + "title": "Runtimemode", + "type": "string" + }, + "startup": { + "$ref": "#/components/schemas/StartupConfig" + }, + "toolBindings": { + "items": { + "$ref": "#/components/schemas/ToolBinding" + }, + "title": "Toolbindings", + "type": "array" + }, + "turnConfig": { + "$ref": "#/components/schemas/TurnConfig" + }, + "type": { + "default": "prompt", + "enum": [ + "prompt", + "workflow", + "dify", + "fastgpt", + "opencode" + ], + "title": "Type", + "type": "string" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + }, + "visionEnabled": { + "default": false, + "title": "Visionenabled", + "type": "boolean" + }, + "visionModelResourceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Visionmodelresourceid" + }, + "visionPrompt": { + "default": "", + "maxLength": 4000, + "title": "Visionprompt", + "type": "string" + }, + "visionPromptMode": { + "default": "runtime", + "enum": [ + "fixed", + "runtime", + "guided" + ], + "title": "Visionpromptmode", + "type": "string" + } + }, + "required": [ + "name", + "id" + ], + "title": "AssistantOut", + "type": "object" + }, + "AssistantUpsert": { + "properties": { + "analysisConfig": { + "$ref": "#/components/schemas/AnalysisConfig" + }, + "apiKey": { + "default": "", + "title": "Apikey", + "type": "string" + }, + "apiUrl": { + "default": "", + "title": "Apiurl", + "type": "string" + }, + "appId": { + "default": "", + "title": "Appid", + "type": "string" + }, + "builtInTools": { + "$ref": "#/components/schemas/BuiltInTools-Input" + }, + "dynamicVariableDefinitions": { + "additionalProperties": { + "$ref": "#/components/schemas/DynamicVariableDefinition" + }, + "title": "Dynamicvariabledefinitions", + "type": "object" + }, + "enableInterrupt": { + "default": true, + "title": "Enableinterrupt", + "type": "boolean" + }, + "graph": { + "additionalProperties": true, + "title": "Graph", + "type": "object" + }, + "greeting": { + "default": "", + "title": "Greeting", + "type": "string" + }, + "knowledgeBaseId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Knowledgebaseid" + }, + "knowledgeRetrievalConfig": { + "$ref": "#/components/schemas/KnowledgeRetrievalConfig" + }, + "modelResourceIds": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "enum": [ + "LLM", + "ASR", + "TTS", + "Realtime", + "Embedding", + "Agent" + ] + }, + "title": "Modelresourceids", + "type": "object" + }, + "name": { + "title": "Name", + "type": "string" + }, + "prompt": { + "default": "", + "title": "Prompt", + "type": "string" + }, + "runtimeMode": { + "default": "pipeline", + "enum": [ + "pipeline", + "realtime" + ], + "title": "Runtimemode", + "type": "string" + }, + "startup": { + "$ref": "#/components/schemas/StartupConfig" + }, + "toolBindings": { + "items": { + "$ref": "#/components/schemas/ToolBinding" + }, + "title": "Toolbindings", + "type": "array" + }, + "turnConfig": { + "$ref": "#/components/schemas/TurnConfig" + }, + "type": { + "default": "prompt", + "enum": [ + "prompt", + "workflow", + "dify", + "fastgpt", + "opencode" + ], + "title": "Type", + "type": "string" + }, + "visionEnabled": { + "default": false, + "title": "Visionenabled", + "type": "boolean" + }, + "visionModelResourceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Visionmodelresourceid" + }, + "visionPrompt": { + "default": "", + "maxLength": 4000, + "title": "Visionprompt", + "type": "string" + }, + "visionPromptMode": { + "default": "runtime", + "enum": [ + "fixed", + "runtime", + "guided" + ], + "title": "Visionpromptmode", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "AssistantUpsert", + "type": "object" + }, + "BargeInConfig": { + "properties": { + "strategy": { + "default": "vad", + "enum": [ + "vad", + "transcription" + ], + "title": "Strategy", + "type": "string" + } + }, + "title": "BargeInConfig", + "type": "object" + }, + "BatchEvaluationResult": { + "properties": { + "actual": { + "title": "Actual", + "type": "string" + }, + "expected": { + "title": "Expected", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "reply", + "tool_call", + "overall" + ], + "title": "Kind", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "reason": { + "default": "", + "title": "Reason", + "type": "string" + }, + "status": { + "enum": [ + "pass", + "fail" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "id", + "label", + "kind", + "status", + "expected", + "actual" + ], + "title": "BatchEvaluationResult", + "type": "object" + }, + "BatchExecutionError": { + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "retryable": { + "title": "Retryable", + "type": "boolean" + }, + "stage": { + "enum": [ + "pipeline", + "model", + "tool", + "evaluation" + ], + "title": "Stage", + "type": "string" + } + }, + "required": [ + "code", + "message", + "stage", + "retryable" + ], + "title": "BatchExecutionError", + "type": "object" + }, + "BatchRunCaseOut": { + "properties": { + "attemptCount": { + "title": "Attemptcount", + "type": "integer" + }, + "executionError": { + "anyOf": [ + { + "$ref": "#/components/schemas/BatchExecutionError" + }, + { + "type": "null" + } + ] + }, + "id": { + "title": "Id", + "type": "string" + }, + "maxAttempts": { + "title": "Maxattempts", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "overallCriteria": { + "items": { + "$ref": "#/components/schemas/BatchEvaluationResult" + }, + "title": "Overallcriteria", + "type": "array" + }, + "startup": { + "$ref": "#/components/schemas/BatchStartupResult" + }, + "status": { + "enum": [ + "waiting", + "running", + "pass", + "fail", + "error", + "skipped" + ], + "title": "Status", + "type": "string" + }, + "turns": { + "items": { + "$ref": "#/components/schemas/BatchTurnResult" + }, + "title": "Turns", + "type": "array" + } + }, + "required": [ + "id", + "name", + "status", + "attemptCount", + "maxAttempts" + ], + "title": "BatchRunCaseOut", + "type": "object" + }, + "BatchRunConfig": { + "properties": { + "concurrency": { + "default": 3, + "maximum": 20.0, + "minimum": 1.0, + "title": "Concurrency", + "type": "integer" + }, + "errorRetryCount": { + "default": 0, + "maximum": 3.0, + "minimum": 0.0, + "title": "Errorretrycount", + "type": "integer" + }, + "errorStrategy": { + "default": "continue", + "enum": [ + "continue", + "stop_on_error" + ], + "title": "Errorstrategy", + "type": "string" + }, + "failureStrategy": { + "default": "continue", + "enum": [ + "continue", + "stop_on_fail" + ], + "title": "Failurestrategy", + "type": "string" + }, + "timeoutSecs": { + "default": 60, + "maximum": 600.0, + "minimum": 1.0, + "title": "Timeoutsecs", + "type": "integer" + } + }, + "title": "BatchRunConfig", + "type": "object" + }, + "BatchRunCreate": { + "properties": { + "assistantId": { + "maxLength": 40, + "minLength": 1, + "title": "Assistantid", + "type": "string" + }, + "caseIds": { + "items": { + "type": "string" + }, + "maxItems": 1000, + "minItems": 1, + "title": "Caseids", + "type": "array" + }, + "config": { + "$ref": "#/components/schemas/BatchRunConfig" + }, + "dynamicVariables": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "maxProperties": 50, + "title": "Dynamicvariables", + "type": "object" + }, + "evaluatorModelResourceId": { + "maxLength": 40, + "minLength": 1, + "title": "Evaluatormodelresourceid", + "type": "string" + }, + "title": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "required": [ + "assistantId", + "evaluatorModelResourceId", + "caseIds" + ], + "title": "BatchRunCreate", + "type": "object" + }, + "BatchRunSnapshotOut": { + "properties": { + "assistantName": { + "title": "Assistantname", + "type": "string" + }, + "cases": { + "items": { + "$ref": "#/components/schemas/BatchRunCaseOut" + }, + "title": "Cases", + "type": "array" + }, + "config": { + "additionalProperties": true, + "title": "Config", + "type": "object" + }, + "finishedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finishedat" + }, + "id": { + "title": "Id", + "type": "string" + }, + "startedAt": { + "format": "date-time", + "title": "Startedat", + "type": "string" + }, + "status": { + "enum": [ + "queued", + "running", + "completed", + "cancelled" + ], + "title": "Status", + "type": "string" + }, + "stopReason": { + "anyOf": [ + { + "enum": [ + "manual", + "assertion_failure", + "execution_error" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stopreason" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "status", + "title", + "assistantName", + "config", + "cases", + "startedAt", + "finishedAt", + "stopReason" + ], + "title": "BatchRunSnapshotOut", + "type": "object" + }, + "BatchStartupResult": { + "properties": { + "assistantOutputs": { + "items": { + "type": "string" + }, + "title": "Assistantoutputs", + "type": "array" + }, + "autoConfirmedMessages": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Autoconfirmedmessages", + "type": "array" + }, + "completed": { + "default": false, + "title": "Completed", + "type": "boolean" + }, + "greeting": { + "default": "", + "title": "Greeting", + "type": "string" + }, + "runtimeRecords": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Runtimerecords", + "type": "array" + } + }, + "title": "BatchStartupResult", + "type": "object" + }, + "BatchToolCallRecord": { + "properties": { + "argumentsJson": { + "title": "Argumentsjson", + "type": "string" + }, + "durationMs": { + "default": 0, + "title": "Durationms", + "type": "integer" + }, + "functionName": { + "title": "Functionname", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "outcome": { + "enum": [ + "success", + "error" + ], + "title": "Outcome", + "type": "string" + }, + "resultJson": { + "title": "Resultjson", + "type": "string" + } + }, + "required": [ + "id", + "functionName", + "argumentsJson", + "resultJson", + "outcome" + ], + "title": "BatchToolCallRecord", + "type": "object" + }, + "BatchTurnResult": { + "properties": { + "assistantReply": { + "title": "Assistantreply", + "type": "string" + }, + "evaluations": { + "items": { + "$ref": "#/components/schemas/BatchEvaluationResult" + }, + "title": "Evaluations", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "image": { + "anyOf": [ + { + "$ref": "#/components/schemas/TestImageRef" + }, + { + "type": "null" + } + ] + }, + "index": { + "title": "Index", + "type": "integer" + }, + "runtimeRecords": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Runtimerecords", + "type": "array" + }, + "toolCalls": { + "items": { + "$ref": "#/components/schemas/BatchToolCallRecord" + }, + "title": "Toolcalls", + "type": "array" + }, + "userInput": { + "title": "Userinput", + "type": "string" + } + }, + "required": [ + "id", + "index", + "userInput", + "assistantReply" + ], + "title": "BatchTurnResult", + "type": "object" + }, + "Body_add_file_api_knowledge_bases__kb_id__documents_file_post": { + "properties": { + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + } + }, + "required": [ + "file" + ], + "title": "Body_add_file_api_knowledge_bases__kb_id__documents_file_post", + "type": "object" + }, + "Body_upload_input_image_api_input_assets_image_post": { + "properties": { + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + } + }, + "required": [ + "file" + ], + "title": "Body_upload_input_image_api_input_assets_image_post", + "type": "object" + }, + "Body_upload_test_image_api_test_assets_images_post": { + "properties": { + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + } + }, + "required": [ + "file" + ], + "title": "Body_upload_test_image_api_test_assets_images_post", + "type": "object" + }, + "BuiltInToolBase": { + "properties": { + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + } + }, + "title": "BuiltInToolBase", + "type": "object" + }, + "BuiltInTools-Input": { + "properties": { + "endConversation": { + "anyOf": [ + { + "$ref": "#/components/schemas/EndConversationTool" + }, + { + "type": "null" + } + ] + }, + "requestHumanHandoff": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltInToolBase" + }, + { + "type": "null" + } + ] + }, + "skipTurn": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltInToolBase" + }, + { + "type": "null" + } + ] + }, + "updateState": { + "anyOf": [ + { + "$ref": "#/components/schemas/UpdateStateTool-Input" + }, + { + "type": "null" + } + ] + } + }, + "title": "BuiltInTools", + "type": "object" + }, + "BuiltInTools-Output": { + "properties": { + "endConversation": { + "anyOf": [ + { + "$ref": "#/components/schemas/EndConversationTool" + }, + { + "type": "null" + } + ] + }, + "requestHumanHandoff": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltInToolBase" + }, + { + "type": "null" + } + ] + }, + "skipTurn": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltInToolBase" + }, + { + "type": "null" + } + ] + }, + "updateState": { + "anyOf": [ + { + "$ref": "#/components/schemas/UpdateStateTool-Output" + }, + { + "type": "null" + } + ] + } + }, + "title": "BuiltInTools", + "type": "object" + }, + "ClientToolConfig": { + "properties": { + "allowInterruptions": { + "default": true, + "title": "Allowinterruptions", + "type": "boolean" + }, + "executionMode": { + "default": "async", + "enum": [ + "immediate", + "async" + ], + "title": "Executionmode", + "type": "string" + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/ToolParameter" + }, + "title": "Parameters", + "type": "array" + }, + "responseWaitMode": { + "default": "timeout", + "enum": [ + "timeout", + "session" + ], + "title": "Responsewaitmode", + "type": "string" + }, + "timeoutSeconds": { + "default": 3, + "maximum": 30.0, + "minimum": 1.0, + "title": "Timeoutseconds", + "type": "integer" + }, + "waitForResponse": { + "default": true, + "title": "Waitforresponse", + "type": "boolean" + } + }, + "title": "ClientToolConfig", + "type": "object" + }, + "ClientToolDefinition": { + "properties": { + "config": { + "$ref": "#/components/schemas/ClientToolConfig" + }, + "schemaVersion": { + "default": 2, + "title": "Schemaversion", + "type": "integer" + }, + "type": { + "const": "client", + "default": "client", + "title": "Type", + "type": "string" + } + }, + "title": "ClientToolDefinition", + "type": "object" + }, + "ConditionalStateExpression-Input": { + "properties": { + "condition": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Input", + "and": "#/components/schemas/OperationStateExpression-Input", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Input", + "eq": "#/components/schemas/OperationStateExpression-Input", + "gt": "#/components/schemas/OperationStateExpression-Input", + "gte": "#/components/schemas/OperationStateExpression-Input", + "if": "#/components/schemas/ConditionalStateExpression-Input", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Input", + "lte": "#/components/schemas/OperationStateExpression-Input", + "multiply": "#/components/schemas/OperationStateExpression-Input", + "neq": "#/components/schemas/OperationStateExpression-Input", + "or": "#/components/schemas/OperationStateExpression-Input", + "subtract": "#/components/schemas/OperationStateExpression-Input", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Input" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Input" + } + ], + "title": "Condition" + }, + "otherwise": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Input", + "and": "#/components/schemas/OperationStateExpression-Input", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Input", + "eq": "#/components/schemas/OperationStateExpression-Input", + "gt": "#/components/schemas/OperationStateExpression-Input", + "gte": "#/components/schemas/OperationStateExpression-Input", + "if": "#/components/schemas/ConditionalStateExpression-Input", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Input", + "lte": "#/components/schemas/OperationStateExpression-Input", + "multiply": "#/components/schemas/OperationStateExpression-Input", + "neq": "#/components/schemas/OperationStateExpression-Input", + "or": "#/components/schemas/OperationStateExpression-Input", + "subtract": "#/components/schemas/OperationStateExpression-Input", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Input" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Input" + } + ], + "title": "Otherwise" + }, + "then": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Input", + "and": "#/components/schemas/OperationStateExpression-Input", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Input", + "eq": "#/components/schemas/OperationStateExpression-Input", + "gt": "#/components/schemas/OperationStateExpression-Input", + "gte": "#/components/schemas/OperationStateExpression-Input", + "if": "#/components/schemas/ConditionalStateExpression-Input", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Input", + "lte": "#/components/schemas/OperationStateExpression-Input", + "multiply": "#/components/schemas/OperationStateExpression-Input", + "neq": "#/components/schemas/OperationStateExpression-Input", + "or": "#/components/schemas/OperationStateExpression-Input", + "subtract": "#/components/schemas/OperationStateExpression-Input", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Input" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Input" + } + ], + "title": "Then" + }, + "type": { + "const": "if", + "default": "if", + "title": "Type", + "type": "string" + } + }, + "required": [ + "condition", + "then", + "otherwise" + ], + "title": "ConditionalStateExpression", + "type": "object" + }, + "ConditionalStateExpression-Output": { + "properties": { + "condition": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Output", + "and": "#/components/schemas/OperationStateExpression-Output", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Output", + "eq": "#/components/schemas/OperationStateExpression-Output", + "gt": "#/components/schemas/OperationStateExpression-Output", + "gte": "#/components/schemas/OperationStateExpression-Output", + "if": "#/components/schemas/ConditionalStateExpression-Output", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Output", + "lte": "#/components/schemas/OperationStateExpression-Output", + "multiply": "#/components/schemas/OperationStateExpression-Output", + "neq": "#/components/schemas/OperationStateExpression-Output", + "or": "#/components/schemas/OperationStateExpression-Output", + "subtract": "#/components/schemas/OperationStateExpression-Output", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Output" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Output" + } + ], + "title": "Condition" + }, + "otherwise": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Output", + "and": "#/components/schemas/OperationStateExpression-Output", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Output", + "eq": "#/components/schemas/OperationStateExpression-Output", + "gt": "#/components/schemas/OperationStateExpression-Output", + "gte": "#/components/schemas/OperationStateExpression-Output", + "if": "#/components/schemas/ConditionalStateExpression-Output", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Output", + "lte": "#/components/schemas/OperationStateExpression-Output", + "multiply": "#/components/schemas/OperationStateExpression-Output", + "neq": "#/components/schemas/OperationStateExpression-Output", + "or": "#/components/schemas/OperationStateExpression-Output", + "subtract": "#/components/schemas/OperationStateExpression-Output", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Output" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Output" + } + ], + "title": "Otherwise" + }, + "then": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Output", + "and": "#/components/schemas/OperationStateExpression-Output", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Output", + "eq": "#/components/schemas/OperationStateExpression-Output", + "gt": "#/components/schemas/OperationStateExpression-Output", + "gte": "#/components/schemas/OperationStateExpression-Output", + "if": "#/components/schemas/ConditionalStateExpression-Output", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Output", + "lte": "#/components/schemas/OperationStateExpression-Output", + "multiply": "#/components/schemas/OperationStateExpression-Output", + "neq": "#/components/schemas/OperationStateExpression-Output", + "or": "#/components/schemas/OperationStateExpression-Output", + "subtract": "#/components/schemas/OperationStateExpression-Output", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Output" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Output" + } + ], + "title": "Then" + }, + "type": { + "const": "if", + "default": "if", + "title": "Type", + "type": "string" + } + }, + "required": [ + "condition", + "then", + "otherwise" + ], + "title": "ConditionalStateExpression", + "type": "object" + }, + "ConstantStateExpression": { + "properties": { + "type": { + "const": "constant", + "default": "constant", + "title": "Type", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "title": "Value" + } + }, + "required": [ + "value" + ], + "title": "ConstantStateExpression", + "type": "object" + }, + "ContextTurn": { + "properties": { + "content": { + "maxLength": 20000, + "title": "Content", + "type": "string" + }, + "isError": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Iserror" + }, + "role": { + "enum": [ + "agent", + "user", + "tool_call", + "tool_result" + ], + "title": "Role", + "type": "string" + }, + "toolCallId": { + "anyOf": [ + { + "maxLength": 128, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Toolcallid" + }, + "toolName": { + "anyOf": [ + { + "maxLength": 128, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Toolname" + } + }, + "required": [ + "role", + "content" + ], + "title": "ContextTurn", + "type": "object" + }, + "ConversationAnalysisFieldOut": { + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "value": { + "title": "Value" + } + }, + "required": [ + "name", + "type" + ], + "title": "ConversationAnalysisFieldOut", + "type": "object" + }, + "ConversationAnalysisOut": { + "properties": { + "completedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Completedat" + }, + "error": { + "default": "", + "title": "Error", + "type": "string" + }, + "fields": { + "items": { + "$ref": "#/components/schemas/ConversationAnalysisFieldOut" + }, + "title": "Fields", + "type": "array" + }, + "status": { + "default": "none", + "enum": [ + "none", + "pending", + "processing", + "completed", + "failed" + ], + "title": "Status", + "type": "string" + } + }, + "title": "ConversationAnalysisOut", + "type": "object" + }, + "ConversationArtifactOut": { + "properties": { + "contentUrl": { + "title": "Contenturl", + "type": "string" + }, + "durationMs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Durationms" + }, + "extra": { + "additionalProperties": true, + "title": "Extra", + "type": "object" + }, + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "mimeType": { + "title": "Mimetype", + "type": "string" + }, + "sizeBytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sizebytes" + } + }, + "required": [ + "id", + "kind", + "contentUrl", + "mimeType" + ], + "title": "ConversationArtifactOut", + "type": "object" + }, + "ConversationDetailOut": { + "properties": { + "analysis": { + "$ref": "#/components/schemas/ConversationAnalysisOut" + }, + "assistantId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assistantid" + }, + "assistantName": { + "title": "Assistantname", + "type": "string" + }, + "channel": { + "title": "Channel", + "type": "string" + }, + "endedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Endedat" + }, + "extra": { + "additionalProperties": true, + "title": "Extra", + "type": "object" + }, + "id": { + "title": "Id", + "type": "string" + }, + "messageCount": { + "title": "Messagecount", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/components/schemas/ConversationMessageOut" + }, + "title": "Messages", + "type": "array" + }, + "runtimeMode": { + "title": "Runtimemode", + "type": "string" + }, + "startedAt": { + "format": "date-time", + "title": "Startedat", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "id", + "assistantId", + "assistantName", + "channel", + "runtimeMode", + "status", + "messageCount", + "startedAt", + "endedAt" + ], + "title": "ConversationDetailOut", + "type": "object" + }, + "ConversationListOut": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ConversationOut" + }, + "title": "Items", + "type": "array" + }, + "page": { + "title": "Page", + "type": "integer" + }, + "pageSize": { + "title": "Pagesize", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "page", + "pageSize" + ], + "title": "ConversationListOut", + "type": "object" + }, + "ConversationMessageOut": { + "properties": { + "artifacts": { + "items": { + "$ref": "#/components/schemas/ConversationArtifactOut" + }, + "title": "Artifacts", + "type": "array" + }, + "content": { + "title": "Content", + "type": "string" + }, + "contentType": { + "title": "Contenttype", + "type": "string" + }, + "extra": { + "additionalProperties": true, + "title": "Extra", + "type": "object" + }, + "id": { + "title": "Id", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "title": "Occurredat", + "type": "string" + }, + "role": { + "enum": [ + "user", + "assistant" + ], + "title": "Role", + "type": "string" + }, + "sequence": { + "title": "Sequence", + "type": "integer" + } + }, + "required": [ + "id", + "sequence", + "role", + "contentType", + "content", + "occurredAt" + ], + "title": "ConversationMessageOut", + "type": "object" + }, + "ConversationOut": { + "properties": { + "assistantId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assistantid" + }, + "assistantName": { + "title": "Assistantname", + "type": "string" + }, + "channel": { + "title": "Channel", + "type": "string" + }, + "endedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Endedat" + }, + "id": { + "title": "Id", + "type": "string" + }, + "messageCount": { + "title": "Messagecount", + "type": "integer" + }, + "runtimeMode": { + "title": "Runtimemode", + "type": "string" + }, + "startedAt": { + "format": "date-time", + "title": "Startedat", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "id", + "assistantId", + "assistantName", + "channel", + "runtimeMode", + "status", + "messageCount", + "startedAt", + "endedAt" + ], + "title": "ConversationOut", + "type": "object" + }, + "DeletedResource": { + "additionalProperties": false, + "properties": { + "deleted": { + "const": true, + "default": true, + "title": "Deleted", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "object": { + "const": "response", + "default": "response", + "title": "Object", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "DeletedResource", + "type": "object" + }, + "DynamicVariableDefinition": { + "properties": { + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Default" + }, + "required": { + "default": false, + "title": "Required", + "type": "boolean" + }, + "type": { + "default": "string", + "enum": [ + "string", + "number", + "boolean" + ], + "title": "Type", + "type": "string" + } + }, + "title": "DynamicVariableDefinition", + "type": "object" + }, + "EndConversationTool": { + "properties": { + "allowEndInterruption": { + "default": false, + "title": "Allowendinterruption", + "type": "boolean" + }, + "captureReason": { + "default": true, + "title": "Capturereason", + "type": "boolean" + }, + "customMessage": { + "default": "", + "maxLength": 2000, + "title": "Custommessage", + "type": "string" + }, + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "messageType": { + "default": "none", + "enum": [ + "none", + "custom" + ], + "title": "Messagetype", + "type": "string" + } + }, + "title": "EndConversationTool", + "type": "object" + }, + "FixedInputTurn": { + "properties": { + "behaviors": { + "items": { + "discriminator": { + "mapping": { + "reply": "#/components/schemas/ReplyExpectedBehavior", + "tool_call": "#/components/schemas/ToolCallExpectedBehavior" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ReplyExpectedBehavior" + }, + { + "$ref": "#/components/schemas/ToolCallExpectedBehavior" + } + ] + }, + "maxItems": 100, + "title": "Behaviors", + "type": "array" + }, + "id": { + "maxLength": 128, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "image": { + "anyOf": [ + { + "$ref": "#/components/schemas/TestImageRef" + }, + { + "type": "null" + } + ] + }, + "userInput": { + "default": "", + "maxLength": 20000, + "title": "Userinput", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "FixedInputTurn", + "type": "object" + }, + "FixedToolSimulation": { + "properties": { + "functionName": { + "maxLength": 128, + "minLength": 1, + "title": "Functionname", + "type": "string" + }, + "mode": { + "const": "fixed", + "default": "fixed", + "title": "Mode", + "type": "string" + }, + "response": { + "$ref": "#/components/schemas/ToolSimulationResponse" + }, + "toolId": { + "maxLength": 40, + "minLength": 1, + "title": "Toolid", + "type": "string" + } + }, + "required": [ + "toolId", + "functionName" + ], + "title": "FixedToolSimulation", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "HttpToolConfig": { + "properties": { + "allowInterruptions": { + "default": true, + "title": "Allowinterruptions", + "type": "boolean" + }, + "body": { + "additionalProperties": true, + "title": "Body", + "type": "object" + }, + "executionMode": { + "default": "immediate", + "enum": [ + "immediate", + "async" + ], + "title": "Executionmode", + "type": "string" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "title": "Headers", + "type": "object" + }, + "method": { + "default": "GET", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "title": "Method", + "type": "string" + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/ToolParameter" + }, + "title": "Parameters", + "type": "array" + }, + "templateInputs": { + "items": { + "$ref": "#/components/schemas/ToolParameter" + }, + "title": "Templateinputs", + "type": "array" + }, + "timeoutSeconds": { + "default": 15, + "maximum": 120.0, + "minimum": 1.0, + "title": "Timeoutseconds", + "type": "integer" + }, + "url": { + "title": "Url", + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "HttpToolConfig", + "type": "object" + }, + "HttpToolDefinition": { + "properties": { + "config": { + "$ref": "#/components/schemas/HttpToolConfig" + }, + "schemaVersion": { + "default": 2, + "title": "Schemaversion", + "type": "integer" + }, + "type": { + "const": "http", + "default": "http", + "title": "Type", + "type": "string" + } + }, + "required": [ + "config" + ], + "title": "HttpToolDefinition", + "type": "object" + }, + "InputImageAssetOut": { + "properties": { + "assetToken": { + "title": "Assettoken", + "type": "string" + }, + "height": { + "title": "Height", + "type": "integer" + }, + "sizeBytes": { + "title": "Sizebytes", + "type": "integer" + }, + "width": { + "title": "Width", + "type": "integer" + } + }, + "required": [ + "assetToken", + "width", + "height", + "sizeBytes" + ], + "title": "InputImageAssetOut", + "type": "object" + }, + "InputText": { + "additionalProperties": false, + "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "type": { + "const": "input_text", + "default": "input_text", + "title": "Type", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "InputText", + "type": "object" + }, + "InteractiveMediaRequestExtension": { + "additionalProperties": false, + "properties": { + "variables": { + "additionalProperties": true, + "title": "Variables", + "type": "object" + }, + "version": { + "anyOf": [ + { + "const": "1", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Interactive Media extension version. Omit or use `1`.", + "title": "Version" + } + }, + "title": "InteractiveMediaRequestExtension", + "type": "object" + }, + "InterfaceDefinitionOut": { + "properties": { + "capability": { + "enum": [ + "LLM", + "ASR", + "TTS", + "Realtime", + "Embedding", + "Agent" + ], + "title": "Capability", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "fieldSchema": { + "additionalProperties": true, + "title": "Fieldschema", + "type": "object" + }, + "interfaceType": { + "title": "Interfacetype", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "integer" + } + }, + "required": [ + "interfaceType", + "name", + "capability", + "fieldSchema", + "enabled", + "version" + ], + "title": "InterfaceDefinitionOut", + "type": "object" + }, + "KnowledgeBaseOut": { + "properties": { + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "embeddingModelResourceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Embeddingmodelresourceid" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "status": { + "default": "active", + "title": "Status", + "type": "string" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + } + }, + "required": [ + "name", + "id" + ], + "title": "KnowledgeBaseOut", + "type": "object" + }, + "KnowledgeBaseUpsert": { + "properties": { + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "embeddingModelResourceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Embeddingmodelresourceid" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "KnowledgeBaseUpsert", + "type": "object" + }, + "KnowledgeChunkOut": { + "properties": { + "chunkIndex": { + "title": "Chunkindex", + "type": "integer" + }, + "content": { + "title": "Content", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + } + }, + "required": [ + "id", + "chunkIndex", + "content" + ], + "title": "KnowledgeChunkOut", + "type": "object" + }, + "KnowledgeDocumentOut": { + "properties": { + "chunkCount": { + "title": "Chunkcount", + "type": "integer" + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Createdat" + }, + "errorMessage": { + "default": "", + "title": "Errormessage", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "knowledgeBaseId": { + "title": "Knowledgebaseid", + "type": "string" + }, + "mimeType": { + "title": "Mimetype", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "sizeBytes": { + "title": "Sizebytes", + "type": "integer" + }, + "sourceType": { + "title": "Sourcetype", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "id", + "knowledgeBaseId", + "name", + "sourceType", + "mimeType", + "sizeBytes", + "status", + "chunkCount" + ], + "title": "KnowledgeDocumentOut", + "type": "object" + }, + "KnowledgeRetrievalConfig": { + "properties": { + "mode": { + "default": "automatic", + "enum": [ + "automatic", + "on_demand" + ], + "title": "Mode", + "type": "string" + }, + "scoreThreshold": { + "default": 0.0, + "maximum": 1.0, + "minimum": 0.0, + "title": "Scorethreshold", + "type": "number" + }, + "topN": { + "default": 5, + "minimum": -1.0, + "title": "Topn", + "type": "integer" + } + }, + "title": "KnowledgeRetrievalConfig", + "type": "object" + }, + "KnowledgeSearchIn": { + "properties": { + "query": { + "minLength": 1, + "title": "Query", + "type": "string" + }, + "topK": { + "default": 5, + "maximum": 20.0, + "minimum": 1.0, + "title": "Topk", + "type": "integer" + } + }, + "required": [ + "query" + ], + "title": "KnowledgeSearchIn", + "type": "object" + }, + "KnowledgeTextIn": { + "properties": { + "content": { + "minLength": 1, + "title": "Content", + "type": "string" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name", + "content" + ], + "title": "KnowledgeTextIn", + "type": "object" + }, + "LiteralValueSource": { + "properties": { + "source": { + "const": "literal", + "default": "literal", + "title": "Source", + "type": "string" + }, + "value": { + "title": "Value" + } + }, + "title": "LiteralValueSource", + "type": "object" + }, + "LlmStateExpression": { + "properties": { + "description": { + "maxLength": 500, + "minLength": 1, + "title": "Description", + "type": "string" + }, + "parameterName": { + "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$", + "title": "Parametername", + "type": "string" + }, + "type": { + "const": "llm", + "default": "llm", + "title": "Type", + "type": "string" + }, + "valueType": { + "default": "string", + "enum": [ + "string", + "number", + "boolean" + ], + "title": "Valuetype", + "type": "string" + } + }, + "required": [ + "parameterName", + "description" + ], + "title": "LlmStateExpression", + "type": "object" + }, + "LoginIn": { + "properties": { + "password": { + "title": "Password", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "LoginIn", + "type": "object" + }, + "McpServerOut": { + "properties": { + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "title": "Headers", + "type": "object" + }, + "id": { + "title": "Id", + "type": "string" + }, + "lastSyncedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lastsyncedat" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "secretHeaders": { + "additionalProperties": { + "type": "string" + }, + "title": "Secretheaders", + "type": "object" + }, + "status": { + "default": "active", + "enum": [ + "active", + "archived", + "draft" + ], + "title": "Status", + "type": "string" + }, + "timeoutSeconds": { + "default": 30, + "maximum": 120.0, + "minimum": 1.0, + "title": "Timeoutseconds", + "type": "integer" + }, + "toolCount": { + "default": 0, + "title": "Toolcount", + "type": "integer" + }, + "transport": { + "default": "streamable_http", + "enum": [ + "streamable_http", + "sse" + ], + "title": "Transport", + "type": "string" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + }, + "url": { + "maxLength": 2048, + "minLength": 1, + "title": "Url", + "type": "string" + } + }, + "required": [ + "name", + "url", + "id" + ], + "title": "McpServerOut", + "type": "object" + }, + "McpServerUpsert": { + "properties": { + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "title": "Headers", + "type": "object" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "secretHeaders": { + "additionalProperties": { + "type": "string" + }, + "title": "Secretheaders", + "type": "object" + }, + "status": { + "default": "active", + "enum": [ + "active", + "archived", + "draft" + ], + "title": "Status", + "type": "string" + }, + "timeoutSeconds": { + "default": 30, + "maximum": 120.0, + "minimum": 1.0, + "title": "Timeoutseconds", + "type": "integer" + }, + "transport": { + "default": "streamable_http", + "enum": [ + "streamable_http", + "sse" + ], + "title": "Transport", + "type": "string" + }, + "url": { + "maxLength": 2048, + "minLength": 1, + "title": "Url", + "type": "string" + } + }, + "required": [ + "name", + "url" + ], + "title": "McpServerUpsert", + "type": "object" + }, + "McpSyncResult": { + "properties": { + "created": { + "title": "Created", + "type": "integer" + }, + "server": { + "$ref": "#/components/schemas/McpServerOut" + }, + "tools": { + "items": { + "$ref": "#/components/schemas/ToolOut" + }, + "title": "Tools", + "type": "array" + }, + "updated": { + "title": "Updated", + "type": "integer" + } + }, + "required": [ + "server", + "created", + "updated", + "tools" + ], + "title": "McpSyncResult", + "type": "object" + }, + "McpToolConfig": { + "properties": { + "allowInterruptions": { + "default": true, + "title": "Allowinterruptions", + "type": "boolean" + }, + "executionMode": { + "default": "immediate", + "enum": [ + "immediate", + "async" + ], + "title": "Executionmode", + "type": "string" + }, + "inputSchema": { + "additionalProperties": true, + "title": "Inputschema", + "type": "object" + }, + "remoteToolName": { + "maxLength": 255, + "minLength": 1, + "title": "Remotetoolname", + "type": "string" + }, + "schemaHash": { + "default": "", + "title": "Schemahash", + "type": "string" + }, + "templateInputs": { + "items": { + "$ref": "#/components/schemas/ToolParameter" + }, + "title": "Templateinputs", + "type": "array" + } + }, + "required": [ + "remoteToolName" + ], + "title": "McpToolConfig", + "type": "object" + }, + "McpToolDefinition": { + "properties": { + "config": { + "$ref": "#/components/schemas/McpToolConfig" + }, + "schemaVersion": { + "default": 2, + "title": "Schemaversion", + "type": "integer" + }, + "type": { + "const": "mcp", + "default": "mcp", + "title": "Type", + "type": "string" + } + }, + "required": [ + "config" + ], + "title": "McpToolDefinition", + "type": "object" + }, + "ModelList": { + "additionalProperties": false, + "examples": [ + { + "data": [ + { + "assistant_id": "asst_xxx", + "assistant_type": "prompt", + "capabilities": [ + "responses", + "realtime" + ], + "created": 1788739200, + "id": "assistant:asst_xxx", + "name": "产品顾问", + "object": "model", + "owned_by": "interactive-media", + "runtime_mode": "pipeline" + } + ], + "object": "list" + } + ], + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ModelObject" + }, + "title": "Data", + "type": "array" + }, + "object": { + "const": "list", + "default": "list", + "title": "Object", + "type": "string" + } + }, + "required": [ + "data" + ], + "title": "ModelList", + "type": "object" + }, + "ModelObject": { + "additionalProperties": false, + "properties": { + "assistant_id": { + "title": "Assistant Id", + "type": "string" + }, + "assistant_type": { + "title": "Assistant Type", + "type": "string" + }, + "capabilities": { + "items": { + "enum": [ + "responses", + "realtime" + ], + "type": "string" + }, + "title": "Capabilities", + "type": "array" + }, + "created": { + "title": "Created", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "object": { + "const": "model", + "default": "model", + "title": "Object", + "type": "string" + }, + "owned_by": { + "default": "interactive-media", + "title": "Owned By", + "type": "string" + }, + "runtime_mode": { + "title": "Runtime Mode", + "type": "string" + } + }, + "required": [ + "id", + "created", + "name", + "capabilities", + "assistant_id", + "assistant_type", + "runtime_mode" + ], + "title": "ModelObject", + "type": "object" + }, + "ModelResourceOut": { + "properties": { + "capability": { + "enum": [ + "LLM", + "ASR", + "TTS", + "Realtime", + "Embedding", + "Agent" + ], + "title": "Capability", + "type": "string" + }, + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "string" + }, + "interfaceType": { + "title": "Interfacetype", + "type": "string" + }, + "isDefault": { + "default": false, + "title": "Isdefault", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "secrets": { + "additionalProperties": true, + "title": "Secrets", + "type": "object" + }, + "supportImageInput": { + "default": false, + "title": "Supportimageinput", + "type": "boolean" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + }, + "values": { + "additionalProperties": true, + "title": "Values", + "type": "object" + } + }, + "required": [ + "name", + "interfaceType", + "id", + "capability" + ], + "title": "ModelResourceOut", + "type": "object" + }, + "ModelResourceTestResult": { + "properties": { + "detail": { + "default": "", + "title": "Detail", + "type": "string" + }, + "latencyMs": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Latencyms" + }, + "message": { + "title": "Message", + "type": "string" + }, + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok", + "message" + ], + "title": "ModelResourceTestResult", + "type": "object" + }, + "ModelResourceUpsert": { + "properties": { + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "interfaceType": { + "title": "Interfacetype", + "type": "string" + }, + "isDefault": { + "default": false, + "title": "Isdefault", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "secrets": { + "additionalProperties": true, + "title": "Secrets", + "type": "object" + }, + "supportImageInput": { + "default": false, + "title": "Supportimageinput", + "type": "boolean" + }, + "values": { + "additionalProperties": true, + "title": "Values", + "type": "object" + } + }, + "required": [ + "name", + "interfaceType" + ], + "title": "ModelResourceUpsert", + "type": "object" + }, + "ModelValueSource": { + "properties": { + "source": { + "const": "model", + "default": "model", + "title": "Source", + "type": "string" + } + }, + "title": "ModelValueSource", + "type": "object" + }, + "OpenAIErrorDetail": { + "additionalProperties": false, + "description": "Error shape consumed by official OpenAI SDKs.", + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "param": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Param" + }, + "type": { + "default": "invalid_request_error", + "title": "Type", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "title": "OpenAIErrorDetail", + "type": "object" + }, + "OpenAIErrorResponse": { + "additionalProperties": false, + "properties": { + "error": { + "$ref": "#/components/schemas/OpenAIErrorDetail" + } + }, + "required": [ + "error" + ], + "title": "OpenAIErrorResponse", + "type": "object" + }, + "OpeningMessageConfig": { + "description": "Built-in Prompt opening interaction; it is not a reusable LLM tool.", + "properties": { + "confirmLabel": { + "default": "确认", + "maxLength": 40, + "minLength": 1, + "title": "Confirmlabel", + "type": "string" + }, + "message": { + "maxLength": 2000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "title": { + "default": "重要提示", + "maxLength": 120, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "message" + ], + "title": "OpeningMessageConfig", + "type": "object" + }, + "OperationStateExpression-Input": { + "properties": { + "operands": { + "items": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Input", + "and": "#/components/schemas/OperationStateExpression-Input", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Input", + "eq": "#/components/schemas/OperationStateExpression-Input", + "gt": "#/components/schemas/OperationStateExpression-Input", + "gte": "#/components/schemas/OperationStateExpression-Input", + "if": "#/components/schemas/ConditionalStateExpression-Input", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Input", + "lte": "#/components/schemas/OperationStateExpression-Input", + "multiply": "#/components/schemas/OperationStateExpression-Input", + "neq": "#/components/schemas/OperationStateExpression-Input", + "or": "#/components/schemas/OperationStateExpression-Input", + "subtract": "#/components/schemas/OperationStateExpression-Input", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Input" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Input" + } + ] + }, + "maxItems": 10, + "minItems": 2, + "title": "Operands", + "type": "array" + }, + "type": { + "enum": [ + "and", + "or", + "eq", + "neq", + "gt", + "gte", + "lt", + "lte", + "add", + "subtract", + "multiply", + "divide" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "operands" + ], + "title": "OperationStateExpression", + "type": "object" + }, + "OperationStateExpression-Output": { + "properties": { + "operands": { + "items": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Output", + "and": "#/components/schemas/OperationStateExpression-Output", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Output", + "eq": "#/components/schemas/OperationStateExpression-Output", + "gt": "#/components/schemas/OperationStateExpression-Output", + "gte": "#/components/schemas/OperationStateExpression-Output", + "if": "#/components/schemas/ConditionalStateExpression-Output", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Output", + "lte": "#/components/schemas/OperationStateExpression-Output", + "multiply": "#/components/schemas/OperationStateExpression-Output", + "neq": "#/components/schemas/OperationStateExpression-Output", + "or": "#/components/schemas/OperationStateExpression-Output", + "subtract": "#/components/schemas/OperationStateExpression-Output", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Output" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Output" + } + ] + }, + "maxItems": 10, + "minItems": 2, + "title": "Operands", + "type": "array" + }, + "type": { + "enum": [ + "and", + "or", + "eq", + "neq", + "gt", + "gte", + "lt", + "lte", + "add", + "subtract", + "multiply", + "divide" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "operands" + ], + "title": "OperationStateExpression", + "type": "object" + }, + "OutputTextInput": { + "additionalProperties": false, + "properties": { + "annotations": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Annotations", + "type": "array" + }, + "text": { + "title": "Text", + "type": "string" + }, + "type": { + "const": "output_text", + "default": "output_text", + "title": "Type", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "OutputTextInput", + "type": "object" + }, + "OverallCriterion": { + "properties": { + "criteria": { + "maxLength": 2000, + "minLength": 1, + "title": "Criteria", + "type": "string" + }, + "id": { + "maxLength": 128, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "name": { + "maxLength": 80, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "type": { + "const": "llm", + "default": "llm", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "name", + "criteria" + ], + "title": "OverallCriterion", + "type": "object" + }, + "PromptImprovementChange": { + "properties": { + "after": { + "maxLength": 100000, + "minLength": 1, + "title": "After", + "type": "string" + }, + "before": { + "maxLength": 100000, + "title": "Before", + "type": "string" + }, + "evidence": { + "items": { + "type": "string" + }, + "maxItems": 8, + "title": "Evidence", + "type": "array" + }, + "reason": { + "maxLength": 4000, + "minLength": 1, + "title": "Reason", + "type": "string" + } + }, + "required": [ + "before", + "after", + "reason" + ], + "title": "PromptImprovementChange", + "type": "object" + }, + "PromptImprovementManualRecommendation": { + "properties": { + "category": { + "enum": [ + "tool", + "test_case", + "other" + ], + "title": "Category", + "type": "string" + }, + "detail": { + "maxLength": 4000, + "minLength": 1, + "title": "Detail", + "type": "string" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "category", + "title", + "detail" + ], + "title": "PromptImprovementManualRecommendation", + "type": "object" + }, + "PromptImprovementOut": { + "properties": { + "diagnosis": { + "maxLength": 4000, + "minLength": 1, + "title": "Diagnosis", + "type": "string" + }, + "evaluatorName": { + "maxLength": 128, + "minLength": 1, + "title": "Evaluatorname", + "type": "string" + }, + "manualRecommendations": { + "items": { + "$ref": "#/components/schemas/PromptImprovementManualRecommendation" + }, + "maxItems": 20, + "title": "Manualrecommendations", + "type": "array" + }, + "promptChange": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptImprovementChange" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "diagnosis", + "evaluatorName" + ], + "title": "PromptImprovementOut", + "type": "object" + }, + "RealtimeApiKeyCreate": { + "properties": { + "expiresAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expiresat" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "RealtimeApiKeyCreate", + "type": "object" + }, + "RealtimeApiKeyCreated": { + "properties": { + "createdAt": { + "format": "date-time", + "title": "Createdat", + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expiresat" + }, + "id": { + "title": "Id", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + }, + "keyPrefix": { + "title": "Keyprefix", + "type": "string" + }, + "lastUsedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lastusedat" + }, + "name": { + "title": "Name", + "type": "string" + }, + "revokedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revokedat" + }, + "status": { + "enum": [ + "active", + "expired", + "revoked" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "id", + "name", + "keyPrefix", + "status", + "createdAt", + "key" + ], + "title": "RealtimeApiKeyCreated", + "type": "object" + }, + "RealtimeApiKeyOut": { + "properties": { + "createdAt": { + "format": "date-time", + "title": "Createdat", + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expiresat" + }, + "id": { + "title": "Id", + "type": "string" + }, + "keyPrefix": { + "title": "Keyprefix", + "type": "string" + }, + "lastUsedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lastusedat" + }, + "name": { + "title": "Name", + "type": "string" + }, + "revokedAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revokedat" + }, + "status": { + "enum": [ + "active", + "expired", + "revoked" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "id", + "name", + "keyPrefix", + "status", + "createdAt" + ], + "title": "RealtimeApiKeyOut", + "type": "object" + }, + "RealtimeClientSecretRequest": { + "additionalProperties": true, + "properties": { + "session": { + "$ref": "#/components/schemas/RealtimeSessionRequest" + } + }, + "required": [ + "session" + ], + "title": "RealtimeClientSecretRequest", + "type": "object" + }, + "RealtimeClientSecretResponse": { + "additionalProperties": false, + "examples": [ + { + "expires_at": 1788739260, + "session": { + "model": "assistant:asst_xxx", + "output_modalities": [ + "audio" + ], + "type": "realtime" + }, + "value": "ek-rt-..." + } + ], + "properties": { + "expires_at": { + "title": "Expires At", + "type": "integer" + }, + "session": { + "additionalProperties": true, + "title": "Session", + "type": "object" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "value", + "expires_at", + "session" + ], + "title": "RealtimeClientSecretResponse", + "type": "object" + }, + "RealtimeSessionRequest": { + "additionalProperties": true, + "properties": { + "audio": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Audio" + }, + "instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Platform-owned in subset v1; requests containing it are rejected.", + "title": "Instructions" + }, + "model": { + "title": "Model", + "type": "string" + }, + "output_modalities": { + "anyOf": [ + { + "items": { + "enum": [ + "audio", + "text" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Output Modalities" + }, + "tool_choice": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Platform-owned in subset v1; requests containing it are rejected.", + "title": "Tool Choice" + }, + "tools": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Platform-owned in subset v1; requests containing it are rejected.", + "title": "Tools" + }, + "turn_detection": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Turn Detection" + }, + "type": { + "anyOf": [ + { + "const": "realtime", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "voice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Platform-owned in subset v1; requests containing it are rejected.", + "title": "Voice" + } + }, + "required": [ + "model" + ], + "title": "RealtimeSessionRequest", + "type": "object" + }, + "ReplyExpectedBehavior": { + "properties": { + "assertionType": { + "enum": [ + "keyword", + "llm" + ], + "title": "Assertiontype", + "type": "string" + }, + "id": { + "maxLength": 128, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "keywordMatchMode": { + "default": "any", + "enum": [ + "any", + "all" + ], + "title": "Keywordmatchmode", + "type": "string" + }, + "keywords": { + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Keywords", + "type": "array" + }, + "llmCriteria": { + "default": "", + "maxLength": 4000, + "title": "Llmcriteria", + "type": "string" + }, + "negateKeywords": { + "default": false, + "title": "Negatekeywords", + "type": "boolean" + }, + "type": { + "const": "reply", + "default": "reply", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "assertionType" + ], + "title": "ReplyExpectedBehavior", + "type": "object" + }, + "ResponseCreateRequest": { + "additionalProperties": true, + "description": "Supported request fields; extras are preserved for semantic rejection.", + "examples": [ + { + "input": "请介绍一下这个产品", + "model": "assistant:asst_xxx", + "store": true + }, + { + "input": "继续说明", + "previous_response_id": "resp_xxx", + "stream": true + } + ], + "properties": { + "input": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "discriminator": { + "mapping": { + "function_call_output": "#/components/schemas/ResponseFunctionCallOutputInput", + "message": "#/components/schemas/ResponseInputMessage" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ResponseInputMessage" + }, + { + "$ref": "#/components/schemas/ResponseFunctionCallOutputInput" + } + ] + }, + "type": "array" + } + ], + "title": "Input" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Required on the first turn; inherited on later turns.", + "examples": [ + "assistant:asst_xxx" + ], + "title": "Model" + }, + "previous_response_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Previous Response Id" + }, + "store": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Stateful subset: omit this field or set it to true.", + "title": "Store" + }, + "stream": { + "default": false, + "title": "Stream", + "type": "boolean" + }, + "x_interactive_media": { + "anyOf": [ + { + "$ref": "#/components/schemas/InteractiveMediaRequestExtension" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "input" + ], + "title": "ResponseCreateRequest", + "type": "object" + }, + "ResponseError": { + "additionalProperties": false, + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "title": "ResponseError", + "type": "object" + }, + "ResponseFunctionCall": { + "additionalProperties": false, + "properties": { + "arguments": { + "title": "Arguments", + "type": "string" + }, + "call_id": { + "title": "Call Id", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "status": { + "enum": [ + "in_progress", + "completed", + "incomplete" + ], + "title": "Status", + "type": "string" + }, + "type": { + "const": "function_call", + "default": "function_call", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "status", + "call_id", + "name", + "arguments" + ], + "title": "ResponseFunctionCall", + "type": "object" + }, + "ResponseFunctionCallOutputInput": { + "additionalProperties": false, + "properties": { + "call_id": { + "title": "Call Id", + "type": "string" + }, + "output": { + "title": "Output" + }, + "type": { + "const": "function_call_output", + "default": "function_call_output", + "title": "Type", + "type": "string" + } + }, + "required": [ + "call_id", + "output" + ], + "title": "ResponseFunctionCallOutputInput", + "type": "object" + }, + "ResponseFunctionCallOutputItem": { + "additionalProperties": false, + "properties": { + "call_id": { + "title": "Call Id", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "output": { + "title": "Output", + "type": "string" + }, + "status": { + "default": "completed", + "enum": [ + "in_progress", + "completed", + "incomplete" + ], + "title": "Status", + "type": "string" + }, + "type": { + "const": "function_call_output", + "default": "function_call_output", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "call_id", + "output" + ], + "title": "ResponseFunctionCallOutputItem", + "type": "object" + }, + "ResponseInputItemList": { + "additionalProperties": false, + "examples": [ + { + "data": [ + { + "content": [ + { + "text": "你好", + "type": "input_text" + } + ], + "id": "msg_input_xxx", + "role": "user", + "status": "completed", + "type": "message" + } + ], + "first_id": "msg_input_xxx", + "has_more": false, + "last_id": "msg_input_xxx", + "object": "list" + } + ], + "properties": { + "data": { + "items": { + "discriminator": { + "mapping": { + "function_call_output": "#/components/schemas/ResponseFunctionCallOutputItem", + "message": "#/components/schemas/ResponseInputMessageItem" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ResponseInputMessageItem" + }, + { + "$ref": "#/components/schemas/ResponseFunctionCallOutputItem" + } + ] + }, + "title": "Data", + "type": "array" + }, + "first_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "First Id" + }, + "has_more": { + "title": "Has More", + "type": "boolean" + }, + "last_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Id" + }, + "object": { + "const": "list", + "default": "list", + "title": "Object", + "type": "string" + } + }, + "required": [ + "data", + "has_more" + ], + "title": "ResponseInputItemList", + "type": "object" + }, + "ResponseInputMessage": { + "additionalProperties": false, + "properties": { + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "discriminator": { + "mapping": { + "input_text": "#/components/schemas/InputText", + "output_text": "#/components/schemas/OutputTextInput" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/InputText" + }, + { + "$ref": "#/components/schemas/OutputTextInput" + } + ] + }, + "type": "array" + } + ], + "title": "Content" + }, + "role": { + "default": "user", + "enum": [ + "user", + "assistant" + ], + "title": "Role", + "type": "string" + }, + "type": { + "const": "message", + "default": "message", + "title": "Type", + "type": "string" + } + }, + "required": [ + "content" + ], + "title": "ResponseInputMessage", + "type": "object" + }, + "ResponseInputMessageItem": { + "additionalProperties": false, + "properties": { + "content": { + "items": { + "discriminator": { + "mapping": { + "input_text": "#/components/schemas/InputText", + "output_text": "#/components/schemas/OutputTextInput" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/InputText" + }, + { + "$ref": "#/components/schemas/OutputTextInput" + } + ] + }, + "title": "Content", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "role": { + "enum": [ + "user", + "assistant" + ], + "title": "Role", + "type": "string" + }, + "status": { + "default": "completed", + "enum": [ + "in_progress", + "completed", + "incomplete" + ], + "title": "Status", + "type": "string" + }, + "type": { + "const": "message", + "default": "message", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "role", + "content" + ], + "title": "ResponseInputMessageItem", + "type": "object" + }, + "ResponseObject": { + "additionalProperties": false, + "examples": [ + { + "completed_at": 1788739201, + "created_at": 1788739200, + "id": "resp_xxx", + "metadata": {}, + "model": "assistant:asst_xxx", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "这是一个实时对话助手。", + "type": "output_text" + } + ], + "id": "msg_xxx", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": false, + "status": "completed", + "store": true, + "tool_choice": "auto", + "tools": [] + } + ], + "properties": { + "completed_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Completed At" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResponseError" + }, + { + "type": "null" + } + ] + }, + "id": { + "title": "Id", + "type": "string" + }, + "incomplete_details": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Incomplete Details" + }, + "instructions": { + "title": "Instructions", + "type": "null" + }, + "metadata": { + "additionalProperties": { + "type": "string" + }, + "title": "Metadata", + "type": "object" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "object": { + "const": "response", + "default": "response", + "title": "Object", + "type": "string" + }, + "output": { + "items": { + "discriminator": { + "mapping": { + "function_call": "#/components/schemas/ResponseFunctionCall", + "message": "#/components/schemas/ResponseOutputMessage" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ResponseOutputMessage" + }, + { + "$ref": "#/components/schemas/ResponseFunctionCall" + } + ] + }, + "title": "Output", + "type": "array" + }, + "parallel_tool_calls": { + "const": false, + "default": false, + "title": "Parallel Tool Calls", + "type": "boolean" + }, + "previous_response_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Previous Response Id" + }, + "status": { + "enum": [ + "in_progress", + "completed", + "failed", + "cancelled", + "incomplete" + ], + "title": "Status", + "type": "string" + }, + "store": { + "const": true, + "default": true, + "title": "Store", + "type": "boolean" + }, + "tool_choice": { + "const": "auto", + "default": "auto", + "title": "Tool Choice", + "type": "string" + }, + "tools": { + "items": {}, + "title": "Tools", + "type": "array" + }, + "usage": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResponseUsage" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "created_at", + "status", + "model", + "output" + ], + "title": "ResponseObject", + "type": "object" + }, + "ResponseOutputMessage": { + "additionalProperties": false, + "properties": { + "content": { + "items": { + "$ref": "#/components/schemas/ResponseOutputText" + }, + "title": "Content", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "role": { + "const": "assistant", + "default": "assistant", + "title": "Role", + "type": "string" + }, + "status": { + "enum": [ + "in_progress", + "completed", + "incomplete" + ], + "title": "Status", + "type": "string" + }, + "type": { + "const": "message", + "default": "message", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "status", + "content" + ], + "title": "ResponseOutputMessage", + "type": "object" + }, + "ResponseOutputText": { + "additionalProperties": false, + "properties": { + "annotations": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Annotations", + "type": "array" + }, + "logprobs": { + "anyOf": [ + { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Logprobs" + }, + "text": { + "title": "Text", + "type": "string" + }, + "type": { + "const": "output_text", + "default": "output_text", + "title": "Type", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "ResponseOutputText", + "type": "object" + }, + "ResponseUsage": { + "additionalProperties": false, + "properties": { + "input_tokens": { + "title": "Input Tokens", + "type": "integer" + }, + "input_tokens_details": { + "additionalProperties": true, + "title": "Input Tokens Details", + "type": "object" + }, + "output_tokens": { + "title": "Output Tokens", + "type": "integer" + }, + "output_tokens_details": { + "additionalProperties": true, + "title": "Output Tokens Details", + "type": "object" + }, + "total_tokens": { + "title": "Total Tokens", + "type": "integer" + } + }, + "required": [ + "input_tokens", + "output_tokens", + "total_tokens" + ], + "title": "ResponseUsage", + "type": "object" + }, + "SequenceToolSimulation": { + "properties": { + "functionName": { + "maxLength": 128, + "minLength": 1, + "title": "Functionname", + "type": "string" + }, + "mode": { + "const": "sequence", + "default": "sequence", + "title": "Mode", + "type": "string" + }, + "steps": { + "items": { + "$ref": "#/components/schemas/ToolSimulationStep" + }, + "maxItems": 10000, + "minItems": 1, + "title": "Steps", + "type": "array" + }, + "toolId": { + "maxLength": 40, + "minLength": 1, + "title": "Toolid", + "type": "string" + } + }, + "required": [ + "toolId", + "functionName", + "steps" + ], + "title": "SequenceToolSimulation", + "type": "object" + }, + "StartupAction": { + "properties": { + "id": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[A-Za-z][A-Za-z0-9_-]*$", + "title": "Id", + "type": "string" + }, + "phase": { + "default": "opening", + "enum": [ + "preflight", + "opening" + ], + "title": "Phase", + "type": "string" + }, + "required": { + "default": true, + "title": "Required", + "type": "boolean" + }, + "toolBinding": { + "$ref": "#/components/schemas/ToolBinding" + } + }, + "required": [ + "id", + "toolBinding" + ], + "title": "StartupAction", + "type": "object" + }, + "StartupConfig": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/StartupAction" + }, + "maxItems": 5, + "title": "Actions", + "type": "array" + }, + "entryMode": { + "default": "wait_user", + "enum": [ + "wait_user", + "generate" + ], + "title": "Entrymode", + "type": "string" + }, + "executionMode": { + "const": "sequential", + "default": "sequential", + "title": "Executionmode", + "type": "string" + }, + "openingMessage": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpeningMessageConfig" + }, + { + "type": "null" + } + ] + }, + "openingMode": { + "default": "interruptible", + "enum": [ + "interruptible", + "playback", + "confirmation" + ], + "title": "Openingmode", + "type": "string" + } + }, + "title": "StartupConfig", + "type": "object" + }, + "StartupToolSimulationScope": { + "properties": { + "phase": { + "const": "startup", + "default": "startup", + "title": "Phase", + "type": "string" + } + }, + "title": "StartupToolSimulationScope", + "type": "object" + }, + "StateUpdateRule-Input": { + "properties": { + "expression": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Input", + "and": "#/components/schemas/OperationStateExpression-Input", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Input", + "eq": "#/components/schemas/OperationStateExpression-Input", + "gt": "#/components/schemas/OperationStateExpression-Input", + "gte": "#/components/schemas/OperationStateExpression-Input", + "if": "#/components/schemas/ConditionalStateExpression-Input", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Input", + "lte": "#/components/schemas/OperationStateExpression-Input", + "multiply": "#/components/schemas/OperationStateExpression-Input", + "neq": "#/components/schemas/OperationStateExpression-Input", + "or": "#/components/schemas/OperationStateExpression-Input", + "subtract": "#/components/schemas/OperationStateExpression-Input", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Input" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Input" + } + ], + "title": "Expression" + }, + "variableName": { + "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$", + "title": "Variablename", + "type": "string" + } + }, + "required": [ + "variableName", + "expression" + ], + "title": "StateUpdateRule", + "type": "object" + }, + "StateUpdateRule-Output": { + "properties": { + "expression": { + "discriminator": { + "mapping": { + "add": "#/components/schemas/OperationStateExpression-Output", + "and": "#/components/schemas/OperationStateExpression-Output", + "constant": "#/components/schemas/ConstantStateExpression", + "divide": "#/components/schemas/OperationStateExpression-Output", + "eq": "#/components/schemas/OperationStateExpression-Output", + "gt": "#/components/schemas/OperationStateExpression-Output", + "gte": "#/components/schemas/OperationStateExpression-Output", + "if": "#/components/schemas/ConditionalStateExpression-Output", + "llm": "#/components/schemas/LlmStateExpression", + "lt": "#/components/schemas/OperationStateExpression-Output", + "lte": "#/components/schemas/OperationStateExpression-Output", + "multiply": "#/components/schemas/OperationStateExpression-Output", + "neq": "#/components/schemas/OperationStateExpression-Output", + "or": "#/components/schemas/OperationStateExpression-Output", + "subtract": "#/components/schemas/OperationStateExpression-Output", + "variable": "#/components/schemas/VariableStateExpression" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ConstantStateExpression" + }, + { + "$ref": "#/components/schemas/VariableStateExpression" + }, + { + "$ref": "#/components/schemas/LlmStateExpression" + }, + { + "$ref": "#/components/schemas/OperationStateExpression-Output" + }, + { + "$ref": "#/components/schemas/ConditionalStateExpression-Output" + } + ], + "title": "Expression" + }, + "variableName": { + "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$", + "title": "Variablename", + "type": "string" + } + }, + "required": [ + "variableName", + "expression" + ], + "title": "StateUpdateRule", + "type": "object" + }, + "TemplateValueSource": { + "properties": { + "source": { + "const": "template", + "default": "template", + "title": "Source", + "type": "string" + }, + "value": { + "title": "Value" + } + }, + "required": [ + "value" + ], + "title": "TemplateValueSource", + "type": "object" + }, + "TestCaseBulkDeleteIn": { + "properties": { + "caseIds": { + "items": { + "type": "string" + }, + "maxItems": 1000, + "minItems": 1, + "title": "Caseids", + "type": "array" + } + }, + "required": [ + "caseIds" + ], + "title": "TestCaseBulkDeleteIn", + "type": "object" + }, + "TestCaseOrderIn": { + "properties": { + "caseIds": { + "items": { + "type": "string" + }, + "maxItems": 1000, + "minItems": 1, + "title": "Caseids", + "type": "array" + } + }, + "required": [ + "caseIds" + ], + "title": "TestCaseOrderIn", + "type": "object" + }, + "TestCaseOut": { + "properties": { + "contextTurns": { + "items": { + "$ref": "#/components/schemas/ContextTurn" + }, + "maxItems": 100, + "title": "Contextturns", + "type": "array" + }, + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "inputMode": { + "default": "fixed_script_text", + "enum": [ + "fixed_script_text", + "fixed_script_turn_voice", + "fixed_script_continuous_voice", + "user_sim_text", + "user_sim_voice" + ], + "title": "Inputmode", + "type": "string" + }, + "lastResult": { + "enum": [ + "pass", + "fail", + "not_run" + ], + "title": "Lastresult", + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "overallCriteria": { + "items": { + "$ref": "#/components/schemas/OverallCriterion" + }, + "maxItems": 50, + "title": "Overallcriteria", + "type": "array" + }, + "sortOrder": { + "title": "Sortorder", + "type": "integer" + }, + "suiteId": { + "title": "Suiteid", + "type": "string" + }, + "toolSimulations": { + "items": { + "discriminator": { + "mapping": { + "fixed": "#/components/schemas/FixedToolSimulation", + "sequence": "#/components/schemas/SequenceToolSimulation" + }, + "propertyName": "mode" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/FixedToolSimulation" + }, + { + "$ref": "#/components/schemas/SequenceToolSimulation" + } + ] + }, + "maxItems": 100, + "title": "Toolsimulations", + "type": "array" + }, + "turns": { + "items": { + "$ref": "#/components/schemas/FixedInputTurn" + }, + "maxItems": 100, + "minItems": 1, + "title": "Turns", + "type": "array" + }, + "updatedAt": { + "format": "date-time", + "title": "Updatedat", + "type": "string" + } + }, + "required": [ + "name", + "turns", + "id", + "suiteId", + "lastResult", + "sortOrder", + "updatedAt" + ], + "title": "TestCaseOut", + "type": "object" + }, + "TestCaseWrite": { + "properties": { + "contextTurns": { + "items": { + "$ref": "#/components/schemas/ContextTurn" + }, + "maxItems": 100, + "title": "Contextturns", + "type": "array" + }, + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "inputMode": { + "default": "fixed_script_text", + "enum": [ + "fixed_script_text", + "fixed_script_turn_voice", + "fixed_script_continuous_voice", + "user_sim_text", + "user_sim_voice" + ], + "title": "Inputmode", + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "overallCriteria": { + "items": { + "$ref": "#/components/schemas/OverallCriterion" + }, + "maxItems": 50, + "title": "Overallcriteria", + "type": "array" + }, + "toolSimulations": { + "items": { + "discriminator": { + "mapping": { + "fixed": "#/components/schemas/FixedToolSimulation", + "sequence": "#/components/schemas/SequenceToolSimulation" + }, + "propertyName": "mode" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/FixedToolSimulation" + }, + { + "$ref": "#/components/schemas/SequenceToolSimulation" + } + ] + }, + "maxItems": 100, + "title": "Toolsimulations", + "type": "array" + }, + "turns": { + "items": { + "$ref": "#/components/schemas/FixedInputTurn" + }, + "maxItems": 100, + "minItems": 1, + "title": "Turns", + "type": "array" + } + }, + "required": [ + "name", + "turns" + ], + "title": "TestCaseWrite", + "type": "object" + }, + "TestImageAssetOut": { + "properties": { + "contentUrl": { + "title": "Contenturl", + "type": "string" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "height": { + "title": "Height", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "string" + }, + "sizeBytes": { + "title": "Sizebytes", + "type": "integer" + }, + "width": { + "title": "Width", + "type": "integer" + } + }, + "required": [ + "id", + "filename", + "width", + "height", + "sizeBytes", + "contentUrl" + ], + "title": "TestImageAssetOut", + "type": "object" + }, + "TestImageRef": { + "properties": { + "assetId": { + "maxLength": 40, + "minLength": 1, + "title": "Assetid", + "type": "string" + }, + "filename": { + "default": "image.jpg", + "maxLength": 255, + "title": "Filename", + "type": "string" + }, + "height": { + "minimum": 1.0, + "title": "Height", + "type": "integer" + }, + "sizeBytes": { + "default": 0, + "minimum": 0.0, + "title": "Sizebytes", + "type": "integer" + }, + "width": { + "minimum": 1.0, + "title": "Width", + "type": "integer" + } + }, + "required": [ + "assetId", + "width", + "height" + ], + "title": "TestImageRef", + "type": "object" + }, + "TestSuiteCreate": { + "properties": { + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "TestSuiteCreate", + "type": "object" + }, + "TestSuiteOut": { + "properties": { + "caseCount": { + "default": 0, + "title": "Casecount", + "type": "integer" + }, + "description": { + "title": "Description", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "passedCount": { + "default": 0, + "title": "Passedcount", + "type": "integer" + }, + "runCount": { + "default": 0, + "title": "Runcount", + "type": "integer" + }, + "updatedAt": { + "format": "date-time", + "title": "Updatedat", + "type": "string" + } + }, + "required": [ + "id", + "name", + "description", + "updatedAt" + ], + "title": "TestSuiteOut", + "type": "object" + }, + "TestSuiteUpdate": { + "properties": { + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "TestSuiteUpdate", + "type": "object" + }, + "ToolBinding": { + "description": "One assistant/stage-specific use of a reusable external tool.", + "properties": { + "argumentBindings": { + "additionalProperties": { + "discriminator": { + "mapping": { + "literal": "#/components/schemas/LiteralValueSource", + "model": "#/components/schemas/ModelValueSource", + "template": "#/components/schemas/TemplateValueSource", + "variable": "#/components/schemas/VariableValueSource" + }, + "propertyName": "source" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ModelValueSource" + }, + { + "$ref": "#/components/schemas/VariableValueSource" + }, + { + "$ref": "#/components/schemas/LiteralValueSource" + }, + { + "$ref": "#/components/schemas/TemplateValueSource" + } + ] + }, + "title": "Argumentbindings", + "type": "object" + }, + "resultAssignments": { + "additionalProperties": { + "type": "string" + }, + "title": "Resultassignments", + "type": "object" + }, + "templateBindings": { + "additionalProperties": { + "discriminator": { + "mapping": { + "literal": "#/components/schemas/LiteralValueSource", + "template": "#/components/schemas/TemplateValueSource", + "variable": "#/components/schemas/VariableValueSource" + }, + "propertyName": "source" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/VariableValueSource" + }, + { + "$ref": "#/components/schemas/LiteralValueSource" + }, + { + "$ref": "#/components/schemas/TemplateValueSource" + } + ] + }, + "title": "Templatebindings", + "type": "object" + }, + "toolId": { + "maxLength": 40, + "minLength": 1, + "title": "Toolid", + "type": "string" + } + }, + "required": [ + "toolId" + ], + "title": "ToolBinding", + "type": "object" + }, + "ToolCallExpectedBehavior": { + "properties": { + "expectation": { + "enum": [ + "called", + "not_called" + ], + "title": "Expectation", + "type": "string" + }, + "functionName": { + "maxLength": 128, + "minLength": 1, + "title": "Functionname", + "type": "string" + }, + "id": { + "maxLength": 128, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "maxCalls": { + "anyOf": [ + { + "maximum": 100.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Maxcalls" + }, + "minCalls": { + "default": 1, + "maximum": 100.0, + "minimum": 0.0, + "title": "Mincalls", + "type": "integer" + }, + "paramAssertions": { + "items": { + "$ref": "#/components/schemas/ToolParamAssertion" + }, + "maxItems": 50, + "title": "Paramassertions", + "type": "array" + }, + "toolId": { + "maxLength": 40, + "minLength": 1, + "title": "Toolid", + "type": "string" + }, + "type": { + "const": "tool_call", + "default": "tool_call", + "title": "Type", + "type": "string" + } + }, + "required": [ + "id", + "toolId", + "functionName", + "expectation" + ], + "title": "ToolCallExpectedBehavior", + "type": "object" + }, + "ToolOut": { + "properties": { + "definition": { + "discriminator": { + "mapping": { + "client": "#/components/schemas/ClientToolDefinition", + "http": "#/components/schemas/HttpToolDefinition", + "mcp": "#/components/schemas/McpToolDefinition" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/HttpToolDefinition" + }, + { + "$ref": "#/components/schemas/ClientToolDefinition" + }, + { + "$ref": "#/components/schemas/McpToolDefinition" + } + ], + "title": "Definition" + }, + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "functionName": { + "pattern": "^[a-z][a-z0-9_]{0,63}$", + "title": "Functionname", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "mcpServerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mcpserverid" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "remoteToolName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Remotetoolname" + }, + "secrets": { + "additionalProperties": true, + "title": "Secrets", + "type": "object" + }, + "status": { + "default": "active", + "enum": [ + "active", + "archived", + "draft" + ], + "title": "Status", + "type": "string" + }, + "type": { + "enum": [ + "http", + "mcp", + "client" + ], + "title": "Type", + "type": "string" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatedat" + } + }, + "required": [ + "name", + "functionName", + "definition", + "id", + "type" + ], + "title": "ToolOut", + "type": "object" + }, + "ToolParamAssertion": { + "properties": { + "matchMode": { + "enum": [ + "exact", + "regex", + "llm" + ], + "title": "Matchmode", + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "value": { + "maxLength": 4000, + "minLength": 1, + "title": "Value", + "type": "string" + } + }, + "required": [ + "name", + "matchMode", + "value" + ], + "title": "ToolParamAssertion", + "type": "object" + }, + "ToolParameter": { + "properties": { + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "location": { + "default": "body", + "enum": [ + "path", + "query", + "body", + "header" + ], + "title": "Location", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "default": true, + "title": "Required", + "type": "boolean" + }, + "type": { + "default": "string", + "enum": [ + "string", + "number", + "integer", + "boolean", + "object", + "array" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ToolParameter", + "type": "object" + }, + "ToolSimulationResponse": { + "properties": { + "body": { + "default": "{\"status\":\"ok\"}", + "maxLength": 100000, + "title": "Body", + "type": "string" + }, + "delayMs": { + "default": 0, + "maximum": 60000.0, + "minimum": 0.0, + "title": "Delayms", + "type": "integer" + }, + "outcome": { + "default": "success", + "enum": [ + "success", + "error" + ], + "title": "Outcome", + "type": "string" + } + }, + "title": "ToolSimulationResponse", + "type": "object" + }, + "ToolSimulationStep": { + "properties": { + "body": { + "default": "{\"status\":\"ok\"}", + "maxLength": 100000, + "title": "Body", + "type": "string" + }, + "delayMs": { + "default": 0, + "maximum": 60000.0, + "minimum": 0.0, + "title": "Delayms", + "type": "integer" + }, + "id": { + "maxLength": 128, + "minLength": 1, + "title": "Id", + "type": "string" + }, + "outcome": { + "default": "success", + "enum": [ + "success", + "error" + ], + "title": "Outcome", + "type": "string" + }, + "scope": { + "discriminator": { + "mapping": { + "any": "#/components/schemas/AnyToolSimulationScope", + "startup": "#/components/schemas/StartupToolSimulationScope", + "turn": "#/components/schemas/TurnToolSimulationScope" + }, + "propertyName": "phase" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/AnyToolSimulationScope" + }, + { + "$ref": "#/components/schemas/StartupToolSimulationScope" + }, + { + "$ref": "#/components/schemas/TurnToolSimulationScope" + } + ], + "title": "Scope" + } + }, + "required": [ + "id" + ], + "title": "ToolSimulationStep", + "type": "object" + }, + "ToolUpsert": { + "properties": { + "definition": { + "discriminator": { + "mapping": { + "client": "#/components/schemas/ClientToolDefinition", + "http": "#/components/schemas/HttpToolDefinition", + "mcp": "#/components/schemas/McpToolDefinition" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/HttpToolDefinition" + }, + { + "$ref": "#/components/schemas/ClientToolDefinition" + }, + { + "$ref": "#/components/schemas/McpToolDefinition" + } + ], + "title": "Definition" + }, + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "functionName": { + "pattern": "^[a-z][a-z0-9_]{0,63}$", + "title": "Functionname", + "type": "string" + }, + "mcpServerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mcpserverid" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "secrets": { + "additionalProperties": true, + "title": "Secrets", + "type": "object" + }, + "status": { + "default": "active", + "enum": [ + "active", + "archived", + "draft" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "name", + "functionName", + "definition" + ], + "title": "ToolUpsert", + "type": "object" + }, + "TurnConfig": { + "properties": { + "bargeIn": { + "$ref": "#/components/schemas/BargeInConfig" + }, + "turnDetection": { + "$ref": "#/components/schemas/TurnDetectionConfig" + }, + "vad": { + "$ref": "#/components/schemas/VadConfig" + } + }, + "title": "TurnConfig", + "type": "object" + }, + "TurnDetectionConfig": { + "properties": { + "silenceTimeoutSecs": { + "default": 0.6, + "maximum": 5.0, + "minimum": 0.2, + "title": "Silencetimeoutsecs", + "type": "number" + }, + "strategy": { + "default": "silence", + "enum": [ + "silence", + "smart_turn" + ], + "title": "Strategy", + "type": "string" + } + }, + "title": "TurnDetectionConfig", + "type": "object" + }, + "TurnToolSimulationScope": { + "properties": { + "phase": { + "const": "turn", + "default": "turn", + "title": "Phase", + "type": "string" + }, + "turnId": { + "maxLength": 128, + "minLength": 1, + "title": "Turnid", + "type": "string" + } + }, + "required": [ + "turnId" + ], + "title": "TurnToolSimulationScope", + "type": "object" + }, + "UpdateStateTool-Input": { + "properties": { + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "updates": { + "items": { + "$ref": "#/components/schemas/StateUpdateRule-Input" + }, + "maxItems": 10, + "title": "Updates", + "type": "array" + } + }, + "title": "UpdateStateTool", + "type": "object" + }, + "UpdateStateTool-Output": { + "properties": { + "description": { + "default": "", + "maxLength": 2048, + "title": "Description", + "type": "string" + }, + "updates": { + "items": { + "$ref": "#/components/schemas/StateUpdateRule-Output" + }, + "maxItems": 10, + "title": "Updates", + "type": "array" + } + }, + "title": "UpdateStateTool", + "type": "object" + }, + "VadConfig": { + "properties": { + "confidence": { + "default": 0.7, + "maximum": 1.0, + "minimum": 0.0, + "title": "Confidence", + "type": "number" + }, + "minVolume": { + "default": 0.6, + "maximum": 1.0, + "minimum": 0.0, + "title": "Minvolume", + "type": "number" + }, + "startSecs": { + "default": 0.2, + "maximum": 2.0, + "minimum": 0.05, + "title": "Startsecs", + "type": "number" + }, + "stopSecs": { + "default": 0.2, + "maximum": 3.0, + "minimum": 0.05, + "title": "Stopsecs", + "type": "number" + } + }, + "title": "VadConfig", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "VariableStateExpression": { + "properties": { + "type": { + "const": "variable", + "default": "variable", + "title": "Type", + "type": "string" + }, + "variableName": { + "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$", + "title": "Variablename", + "type": "string" + } + }, + "required": [ + "variableName" + ], + "title": "VariableStateExpression", + "type": "object" + }, + "VariableValueSource": { + "properties": { + "source": { + "const": "variable", + "default": "variable", + "title": "Source", + "type": "string" + }, + "variableName": { + "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$", + "title": "Variablename", + "type": "string" + } + }, + "required": [ + "variableName" + ], + "title": "VariableValueSource", + "type": "object" + } + }, + "securitySchemes": { + "PublicAPIKey": { + "description": "Long-lived sk-rt-... API key or an endpoint-approved client secret.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Public protocol: OpenAI-compatible subset v1.", + "title": "AI Video Assistant 平台 - 后端", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/assistants": { + "get": { + "operationId": "list_assistants_api_assistants_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssistantOut" + }, + "title": "Response List Assistants Api Assistants Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Assistants", + "tags": [ + "assistants" + ] + }, + "post": { + "operationId": "create_assistant_api_assistants_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Assistant", + "tags": [ + "assistants" + ] + } + }, + "/api/assistants/{assistant_id}": { + "delete": { + "operationId": "delete_assistant_api_assistants__assistant_id__delete", + "parameters": [ + { + "in": "path", + "name": "assistant_id", + "required": true, + "schema": { + "title": "Assistant Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Assistant", + "tags": [ + "assistants" + ] + }, + "get": { + "operationId": "get_assistant_api_assistants__assistant_id__get", + "parameters": [ + { + "in": "path", + "name": "assistant_id", + "required": true, + "schema": { + "title": "Assistant Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Assistant", + "tags": [ + "assistants" + ] + }, + "put": { + "operationId": "update_assistant_api_assistants__assistant_id__put", + "parameters": [ + { + "in": "path", + "name": "assistant_id", + "required": true, + "schema": { + "title": "Assistant Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Assistant", + "tags": [ + "assistants" + ] + } + }, + "/api/assistants/{assistant_id}/duplicate": { + "post": { + "operationId": "duplicate_assistant_api_assistants__assistant_id__duplicate_post", + "parameters": [ + { + "in": "path", + "name": "assistant_id", + "required": true, + "schema": { + "title": "Assistant Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistantOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Duplicate Assistant", + "tags": [ + "assistants" + ] + } + }, + "/api/auth/login": { + "post": { + "operationId": "login_api_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminUserOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Login", + "tags": [ + "auth" + ] + } + }, + "/api/auth/logout": { + "post": { + "operationId": "logout_api_auth_logout_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Logout", + "tags": [ + "auth" + ] + } + }, + "/api/auth/me": { + "get": { + "operationId": "me_api_auth_me_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminUserOut" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Me", + "tags": [ + "auth" + ] + } + }, + "/api/conversations": { + "get": { + "operationId": "list_conversations_api_conversations_get", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "title": "Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Page Size", + "type": "integer" + } + }, + { + "in": "query", + "name": "assistant_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assistant Id" + } + }, + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "anyOf": [ + { + "maxLength": 128, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Search" + } + }, + { + "in": "query", + "name": "channel", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Channel" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "sort_order", + "required": false, + "schema": { + "default": "newest", + "pattern": "^(newest|oldest)$", + "title": "Sort Order", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationListOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Conversations", + "tags": [ + "conversations" + ] + } + }, + "/api/conversations/{conversation_id}": { + "delete": { + "operationId": "delete_conversation_api_conversations__conversation_id__delete", + "parameters": [ + { + "in": "path", + "name": "conversation_id", + "required": true, + "schema": { + "title": "Conversation Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Conversation", + "tags": [ + "conversations" + ] + }, + "get": { + "operationId": "get_conversation_api_conversations__conversation_id__get", + "parameters": [ + { + "in": "path", + "name": "conversation_id", + "required": true, + "schema": { + "title": "Conversation Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationDetailOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Conversation", + "tags": [ + "conversations" + ] + } + }, + "/api/conversations/{conversation_id}/artifacts/{artifact_id}/content": { + "get": { + "operationId": "get_conversation_artifact_api_conversations__conversation_id__artifacts__artifact_id__content_get", + "parameters": [ + { + "in": "path", + "name": "conversation_id", + "required": true, + "schema": { + "title": "Conversation Id", + "type": "string" + } + }, + { + "in": "path", + "name": "artifact_id", + "required": true, + "schema": { + "title": "Artifact Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Conversation Artifact", + "tags": [ + "conversations" + ] + } + }, + "/api/input-assets/image": { + "post": { + "operationId": "upload_input_image_api_input_assets_image_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_input_image_api_input_assets_image_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InputImageAssetOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Input Image", + "tags": [ + "input-assets" + ] + } + }, + "/api/input-assets/{asset_token}": { + "delete": { + "operationId": "delete_input_image_api_input_assets__asset_token__delete", + "parameters": [ + { + "in": "path", + "name": "asset_token", + "required": true, + "schema": { + "title": "Asset Token", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Input Image", + "tags": [ + "input-assets" + ] + } + }, + "/api/interface-definitions": { + "get": { + "operationId": "list_interface_definitions_api_interface_definitions_get", + "parameters": [ + { + "in": "query", + "name": "capability", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Capability" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/InterfaceDefinitionOut" + }, + "title": "Response List Interface Definitions Api Interface Definitions Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Interface Definitions", + "tags": [ + "model-registry" + ] + } + }, + "/api/knowledge-bases": { + "get": { + "operationId": "list_knowledge_bases_api_knowledge_bases_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/KnowledgeBaseOut" + }, + "title": "Response List Knowledge Bases Api Knowledge Bases Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Knowledge Bases", + "tags": [ + "knowledge-bases" + ] + }, + "post": { + "operationId": "create_knowledge_base_api_knowledge_bases_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Knowledge Base", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/knowledge-bases/{kb_id}": { + "delete": { + "operationId": "delete_knowledge_base_api_knowledge_bases__kb_id__delete", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Knowledge Base", + "tags": [ + "knowledge-bases" + ] + }, + "get": { + "operationId": "get_knowledge_base_api_knowledge_bases__kb_id__get", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Knowledge Base", + "tags": [ + "knowledge-bases" + ] + }, + "put": { + "operationId": "update_knowledge_base_api_knowledge_bases__kb_id__put", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Knowledge Base", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/knowledge-bases/{kb_id}/documents": { + "get": { + "operationId": "list_documents_api_knowledge_bases__kb_id__documents_get", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/KnowledgeDocumentOut" + }, + "title": "Response List Documents Api Knowledge Bases Kb Id Documents Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Documents", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/knowledge-bases/{kb_id}/documents/file": { + "post": { + "operationId": "add_file_api_knowledge_bases__kb_id__documents_file_post", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_add_file_api_knowledge_bases__kb_id__documents_file_post" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeDocumentOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Add File", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/knowledge-bases/{kb_id}/documents/text": { + "post": { + "operationId": "add_text_api_knowledge_bases__kb_id__documents_text_post", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeTextIn" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeDocumentOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Add Text", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/knowledge-bases/{kb_id}/documents/{document_id}": { + "delete": { + "operationId": "delete_document_api_knowledge_bases__kb_id__documents__document_id__delete", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + }, + { + "in": "path", + "name": "document_id", + "required": true, + "schema": { + "title": "Document Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Document", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/knowledge-bases/{kb_id}/documents/{document_id}/chunks": { + "get": { + "operationId": "list_document_chunks_api_knowledge_bases__kb_id__documents__document_id__chunks_get", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + }, + { + "in": "path", + "name": "document_id", + "required": true, + "schema": { + "title": "Document Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/KnowledgeChunkOut" + }, + "title": "Response List Document Chunks Api Knowledge Bases Kb Id Documents Document Id Chunks Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Document Chunks", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/knowledge-bases/{kb_id}/documents/{document_id}/retry": { + "post": { + "operationId": "retry_document_api_knowledge_bases__kb_id__documents__document_id__retry_post", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + }, + { + "in": "path", + "name": "document_id", + "required": true, + "schema": { + "title": "Document Id", + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeDocumentOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Retry Document", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/knowledge-bases/{kb_id}/search": { + "post": { + "operationId": "search_knowledge_base_api_knowledge_bases__kb_id__search_post", + "parameters": [ + { + "in": "path", + "name": "kb_id", + "required": true, + "schema": { + "title": "Kb Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeSearchIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Search Knowledge Base", + "tags": [ + "knowledge-bases" + ] + } + }, + "/api/mcp-servers": { + "get": { + "operationId": "list_mcp_servers_api_mcp_servers_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/McpServerOut" + }, + "title": "Response List Mcp Servers Api Mcp Servers Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Mcp Servers", + "tags": [ + "mcp-servers" + ] + }, + "post": { + "operationId": "create_mcp_server_api_mcp_servers_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Mcp Server", + "tags": [ + "mcp-servers" + ] + } + }, + "/api/mcp-servers/{server_id}": { + "delete": { + "operationId": "delete_mcp_server_api_mcp_servers__server_id__delete", + "parameters": [ + { + "in": "path", + "name": "server_id", + "required": true, + "schema": { + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Mcp Server", + "tags": [ + "mcp-servers" + ] + }, + "get": { + "operationId": "get_mcp_server_api_mcp_servers__server_id__get", + "parameters": [ + { + "in": "path", + "name": "server_id", + "required": true, + "schema": { + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Mcp Server", + "tags": [ + "mcp-servers" + ] + }, + "put": { + "operationId": "update_mcp_server_api_mcp_servers__server_id__put", + "parameters": [ + { + "in": "path", + "name": "server_id", + "required": true, + "schema": { + "title": "Server Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Mcp Server", + "tags": [ + "mcp-servers" + ] + } + }, + "/api/mcp-servers/{server_id}/sync": { + "post": { + "operationId": "sync_mcp_tools_api_mcp_servers__server_id__sync_post", + "parameters": [ + { + "in": "path", + "name": "server_id", + "required": true, + "schema": { + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpSyncResult" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Sync Mcp Tools", + "tags": [ + "mcp-servers" + ] + } + }, + "/api/mcp-servers/{server_id}/tools": { + "get": { + "operationId": "list_mcp_tools_api_mcp_servers__server_id__tools_get", + "parameters": [ + { + "in": "path", + "name": "server_id", + "required": true, + "schema": { + "title": "Server Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ToolOut" + }, + "title": "Response List Mcp Tools Api Mcp Servers Server Id Tools Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Mcp Tools", + "tags": [ + "mcp-servers" + ] + } + }, + "/api/model-resources": { + "get": { + "operationId": "list_model_resources_api_model_resources_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ModelResourceOut" + }, + "title": "Response List Model Resources Api Model Resources Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Model Resources", + "tags": [ + "model-registry" + ] + }, + "post": { + "operationId": "create_model_resource_api_model_resources_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Model Resource", + "tags": [ + "model-registry" + ] + } + }, + "/api/model-resources/test": { + "post": { + "operationId": "test_new_model_resource_api_model_resources_test_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceTestResult" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Test New Model Resource", + "tags": [ + "model-registry" + ] + } + }, + "/api/model-resources/{resource_id}": { + "delete": { + "operationId": "delete_model_resource_api_model_resources__resource_id__delete", + "parameters": [ + { + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "title": "Resource Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Model Resource", + "tags": [ + "model-registry" + ] + }, + "put": { + "operationId": "update_model_resource_api_model_resources__resource_id__put", + "parameters": [ + { + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "title": "Resource Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Model Resource", + "tags": [ + "model-registry" + ] + } + }, + "/api/model-resources/{resource_id}/duplicate": { + "post": { + "operationId": "duplicate_model_resource_api_model_resources__resource_id__duplicate_post", + "parameters": [ + { + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "title": "Resource Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Duplicate Model Resource", + "tags": [ + "model-registry" + ] + } + }, + "/api/model-resources/{resource_id}/test": { + "post": { + "operationId": "test_saved_model_resource_api_model_resources__resource_id__test_post", + "parameters": [ + { + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "title": "Resource Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelResourceTestResult" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Test Saved Model Resource", + "tags": [ + "model-registry" + ] + } + }, + "/api/node-types": { + "get": { + "operationId": "list_node_types_api_node_types_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "List Node Types", + "tags": [ + "workflow" + ] + } + }, + "/api/realtime/api-keys": { + "get": { + "operationId": "list_realtime_api_keys_api_realtime_api_keys_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RealtimeApiKeyOut" + }, + "title": "Response List Realtime Api Keys Api Realtime Api Keys Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Realtime Api Keys", + "tags": [ + "realtime-api-keys" + ] + }, + "post": { + "operationId": "create_realtime_api_key_api_realtime_api_keys_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RealtimeApiKeyCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RealtimeApiKeyCreated" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Realtime Api Key", + "tags": [ + "realtime-api-keys" + ] + } + }, + "/api/realtime/api-keys/{key_id}": { + "delete": { + "operationId": "revoke_realtime_api_key_api_realtime_api_keys__key_id__delete", + "parameters": [ + { + "in": "path", + "name": "key_id", + "required": true, + "schema": { + "title": "Key Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Revoke Realtime Api Key", + "tags": [ + "realtime-api-keys" + ] + } + }, + "/api/settings/analysis-webhook": { + "get": { + "operationId": "get_analysis_webhook_api_settings_analysis_webhook_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalysisWebhookSettingOut" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Analysis Webhook", + "tags": [ + "site-settings" + ] + }, + "put": { + "operationId": "update_analysis_webhook_api_settings_analysis_webhook_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalysisWebhookSettingUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalysisWebhookSettingOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Analysis Webhook", + "tags": [ + "site-settings" + ] + } + }, + "/api/settings/analysis-webhook/test": { + "post": { + "operationId": "test_analysis_webhook_api_settings_analysis_webhook_test_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalysisWebhookTestIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalysisWebhookTestOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Test Analysis Webhook", + "tags": [ + "site-settings" + ] + } + }, + "/api/test-assets/images": { + "post": { + "operationId": "upload_test_image_api_test_assets_images_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_test_image_api_test_assets_images_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestImageAssetOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Test Image", + "tags": [ + "test-assets" + ] + } + }, + "/api/test-assets/{asset_id}": { + "delete": { + "operationId": "delete_test_asset_api_test_assets__asset_id__delete", + "parameters": [ + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "title": "Asset Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Test Asset", + "tags": [ + "test-assets" + ] + }, + "get": { + "operationId": "get_test_asset_api_test_assets__asset_id__get", + "parameters": [ + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "title": "Asset Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestImageAssetOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Test Asset", + "tags": [ + "test-assets" + ] + } + }, + "/api/test-assets/{asset_id}/content": { + "get": { + "operationId": "get_test_asset_content_api_test_assets__asset_id__content_get", + "parameters": [ + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "title": "Asset Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Test Asset Content", + "tags": [ + "test-assets" + ] + } + }, + "/api/test-cases": { + "get": { + "operationId": "list_test_cases_api_test_cases_get", + "parameters": [ + { + "in": "query", + "name": "suiteId", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Suiteid" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TestCaseOut" + }, + "title": "Response List Test Cases Api Test Cases Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Test Cases", + "tags": [ + "tests" + ] + } + }, + "/api/test-cases/bulk-delete": { + "post": { + "operationId": "bulk_delete_test_cases_api_test_cases_bulk_delete_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestCaseBulkDeleteIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Bulk Delete Test Cases", + "tags": [ + "tests" + ] + } + }, + "/api/test-cases/{case_id}": { + "delete": { + "operationId": "delete_test_case_api_test_cases__case_id__delete", + "parameters": [ + { + "in": "path", + "name": "case_id", + "required": true, + "schema": { + "title": "Case Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Test Case", + "tags": [ + "tests" + ] + }, + "get": { + "operationId": "get_test_case_api_test_cases__case_id__get", + "parameters": [ + { + "in": "path", + "name": "case_id", + "required": true, + "schema": { + "title": "Case Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestCaseOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Test Case", + "tags": [ + "tests" + ] + }, + "put": { + "operationId": "update_test_case_api_test_cases__case_id__put", + "parameters": [ + { + "in": "path", + "name": "case_id", + "required": true, + "schema": { + "title": "Case Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestCaseWrite" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestCaseOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Test Case", + "tags": [ + "tests" + ] + } + }, + "/api/test-cases/{case_id}/duplicate": { + "post": { + "operationId": "duplicate_test_case_api_test_cases__case_id__duplicate_post", + "parameters": [ + { + "in": "path", + "name": "case_id", + "required": true, + "schema": { + "title": "Case Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestCaseOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Duplicate Test Case", + "tags": [ + "tests" + ] + } + }, + "/api/test-runs": { + "post": { + "operationId": "create_test_run_api_test_runs_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchRunCreate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchRunSnapshotOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Test Run", + "tags": [ + "tests" + ] + } + }, + "/api/test-runs/{run_id}": { + "get": { + "operationId": "get_test_run_api_test_runs__run_id__get", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchRunSnapshotOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Test Run", + "tags": [ + "tests" + ] + } + }, + "/api/test-runs/{run_id}/cancel": { + "post": { + "operationId": "cancel_test_run_api_test_runs__run_id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchRunSnapshotOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Cancel Test Run", + "tags": [ + "tests" + ] + } + }, + "/api/test-runs/{run_id}/prompt-improvement": { + "post": { + "operationId": "create_prompt_improvement_api_test_runs__run_id__prompt_improvement_post", + "parameters": [ + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "title": "Run Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PromptImprovementOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Prompt Improvement", + "tags": [ + "tests" + ] + } + }, + "/api/test-suites": { + "get": { + "operationId": "list_test_suites_api_test_suites_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TestSuiteOut" + }, + "title": "Response List Test Suites Api Test Suites Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Test Suites", + "tags": [ + "tests" + ] + }, + "post": { + "operationId": "create_test_suite_api_test_suites_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSuiteCreate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSuiteOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Test Suite", + "tags": [ + "tests" + ] + } + }, + "/api/test-suites/{suite_id}": { + "delete": { + "operationId": "delete_test_suite_api_test_suites__suite_id__delete", + "parameters": [ + { + "in": "path", + "name": "suite_id", + "required": true, + "schema": { + "title": "Suite Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Test Suite", + "tags": [ + "tests" + ] + }, + "get": { + "operationId": "get_test_suite_api_test_suites__suite_id__get", + "parameters": [ + { + "in": "path", + "name": "suite_id", + "required": true, + "schema": { + "title": "Suite Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSuiteOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Test Suite", + "tags": [ + "tests" + ] + }, + "put": { + "operationId": "update_test_suite_api_test_suites__suite_id__put", + "parameters": [ + { + "in": "path", + "name": "suite_id", + "required": true, + "schema": { + "title": "Suite Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSuiteUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSuiteOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Test Suite", + "tags": [ + "tests" + ] + } + }, + "/api/test-suites/{suite_id}/case-order": { + "put": { + "operationId": "reorder_test_cases_api_test_suites__suite_id__case_order_put", + "parameters": [ + { + "in": "path", + "name": "suite_id", + "required": true, + "schema": { + "title": "Suite Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestCaseOrderIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Reorder Test Cases", + "tags": [ + "tests" + ] + } + }, + "/api/test-suites/{suite_id}/cases": { + "post": { + "operationId": "create_test_case_api_test_suites__suite_id__cases_post", + "parameters": [ + { + "in": "path", + "name": "suite_id", + "required": true, + "schema": { + "title": "Suite Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestCaseWrite" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestCaseOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Test Case", + "tags": [ + "tests" + ] + } + }, + "/api/test-suites/{suite_id}/duplicate": { + "post": { + "operationId": "duplicate_test_suite_api_test_suites__suite_id__duplicate_post", + "parameters": [ + { + "in": "path", + "name": "suite_id", + "required": true, + "schema": { + "title": "Suite Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSuiteOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Duplicate Test Suite", + "tags": [ + "tests" + ] + } + }, + "/api/tools": { + "get": { + "operationId": "list_tools_api_tools_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ToolOut" + }, + "title": "Response List Tools Api Tools Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Tools", + "tags": [ + "tools" + ] + }, + "post": { + "operationId": "create_tool_api_tools_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Tool", + "tags": [ + "tools" + ] + } + }, + "/api/tools/{tool_id}": { + "delete": { + "operationId": "delete_tool_api_tools__tool_id__delete", + "parameters": [ + { + "in": "path", + "name": "tool_id", + "required": true, + "schema": { + "title": "Tool Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Tool", + "tags": [ + "tools" + ] + }, + "get": { + "operationId": "get_tool_api_tools__tool_id__get", + "parameters": [ + { + "in": "path", + "name": "tool_id", + "required": true, + "schema": { + "title": "Tool Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Tool", + "tags": [ + "tools" + ] + }, + "put": { + "operationId": "update_tool_api_tools__tool_id__put", + "parameters": [ + { + "in": "path", + "name": "tool_id", + "required": true, + "schema": { + "title": "Tool Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolUpsert" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Tool", + "tags": [ + "tools" + ] + } + }, + "/api/tools/{tool_id}/duplicate": { + "post": { + "operationId": "duplicate_tool_api_tools__tool_id__duplicate_post", + "parameters": [ + { + "in": "path", + "name": "tool_id", + "required": true, + "schema": { + "title": "Tool Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Duplicate Tool", + "tags": [ + "tools" + ] + } + }, + "/api/webrtc/ice-servers": { + "get": { + "description": "Browser fetches STUN/TURN config (with ephemeral TURN creds when configured).", + "operationId": "ice_servers_api_webrtc_ice_servers_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Ice Servers", + "tags": [ + "voice" + ] + } + }, + "/api/webrtc/offer": { + "patch": { + "description": "Accept the batched trickle ICE format used by the official JS client.", + "operationId": "http_ice_candidates_api_webrtc_offer_patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Http Ice Candidates", + "tags": [ + "voice" + ] + }, + "post": { + "description": "Official SmallWebRTC JS transport offer endpoint.", + "operationId": "http_offer_api_webrtc_offer_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Http Offer", + "tags": [ + "voice" + ] + } + }, + "/health": { + "get": { + "operationId": "health_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Health" + } + }, + "/v1/models": { + "get": { + "operationId": "list_models_v1_models_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelList" + } + } + }, + "description": "Successful Response", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unsupported_parameter", + "message": "The requested parameter is not supported.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The request is unsupported or semantically invalid.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_api_key", + "message": "Invalid or expired API key.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The API credential is missing, invalid, or expired.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_found", + "message": "Response not found.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The resource does not exist or belongs to another API key.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_ready", + "message": "The requested state transition conflicts.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The requested state transition conflicts with current state.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_request_error", + "message": "Request does not match the API schema.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The JSON body or query parameters do not match the schema.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/json": { + "example": { + "error": { + "code": "rate_limit_exceeded", + "message": "The request limit was exceeded.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "A request or concurrency limit was exceeded.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "example": { + "error": { + "code": "internal_error", + "message": "The response failed unexpectedly.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response failed because of an internal error.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service_unavailable", + "message": "The service is temporarily unavailable.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "504": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_timeout", + "message": "Response timed out.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response exceeded its generation timeout.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "PublicAPIKey": [] + } + ], + "summary": "List assistants available through the public API", + "tags": [ + "openai-models" + ] + } + }, + "/v1/realtime/calls": { + "post": { + "operationId": "create_realtime_call_v1_realtime_calls_post", + "requestBody": { + "content": { + "application/sdp": { + "schema": { + "format": "sdp", + "type": "string" + } + }, + "multipart/form-data": { + "schema": { + "properties": { + "sdp": { + "format": "sdp", + "type": "string" + }, + "session": { + "description": "JSON-encoded Realtime session.", + "type": "string" + } + }, + "required": [ + "sdp", + "session" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/sdp": { + "example": "v=0\\r\\no=- ...", + "schema": { + "format": "sdp", + "type": "string" + } + } + }, + "description": "WebRTC SDP answer.", + "headers": { + "Location": { + "description": "Relative URL of the created Realtime call.", + "schema": { + "type": "string" + } + }, + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unsupported_parameter", + "message": "The requested parameter is not supported.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The request is unsupported or semantically invalid.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_api_key", + "message": "Invalid or expired API key.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The API credential is missing, invalid, or expired.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_found", + "message": "Response not found.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The resource does not exist or belongs to another API key.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_ready", + "message": "The requested state transition conflicts.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The requested state transition conflicts with current state.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_request_error", + "message": "Request does not match the API schema.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The JSON body or query parameters do not match the schema.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/json": { + "example": { + "error": { + "code": "rate_limit_exceeded", + "message": "The request limit was exceeded.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "A request or concurrency limit was exceeded.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "example": { + "error": { + "code": "internal_error", + "message": "The response failed unexpectedly.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response failed because of an internal error.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service_unavailable", + "message": "The service is temporarily unavailable.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "504": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_timeout", + "message": "Response timed out.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response exceeded its generation timeout.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "PublicAPIKey": [] + } + ], + "summary": "Create a Realtime WebRTC call", + "tags": [ + "openai-realtime" + ] + } + }, + "/v1/realtime/client_secrets": { + "post": { + "operationId": "create_realtime_client_secret_v1_realtime_client_secrets_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/RealtimeClientSecretRequest" + }, + { + "$ref": "#/components/schemas/RealtimeSessionRequest" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RealtimeClientSecretResponse" + } + } + }, + "description": "Successful Response", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unsupported_parameter", + "message": "The requested parameter is not supported.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The request is unsupported or semantically invalid.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_api_key", + "message": "Invalid or expired API key.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The API credential is missing, invalid, or expired.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_found", + "message": "Response not found.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The resource does not exist or belongs to another API key.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_ready", + "message": "The requested state transition conflicts.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The requested state transition conflicts with current state.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_request_error", + "message": "Request does not match the API schema.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The JSON body or query parameters do not match the schema.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/json": { + "example": { + "error": { + "code": "rate_limit_exceeded", + "message": "The request limit was exceeded.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "A request or concurrency limit was exceeded.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "example": { + "error": { + "code": "internal_error", + "message": "The response failed unexpectedly.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response failed because of an internal error.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service_unavailable", + "message": "The service is temporarily unavailable.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "504": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_timeout", + "message": "Response timed out.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response exceeded its generation timeout.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "PublicAPIKey": [] + } + ], + "summary": "Create a short-lived Realtime client secret", + "tags": [ + "openai-realtime" + ] + } + }, + "/v1/responses": { + "post": { + "operationId": "create_response_v1_responses_post", + "parameters": [ + { + "description": "Retry key scoped to the authenticated API key.", + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Retry key scoped to the authenticated API key.", + "title": "Idempotency-Key" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseObject" + } + }, + "text/event-stream": { + "example": "event: response.created\\ndata: {\"type\":\"response.created\",...}\\n\\n", + "schema": { + "type": "string" + } + } + }, + "description": "A completed Response or a stream of Responses events.", + "headers": { + "X-Idempotent-Replayed": { + "description": "Present and true when a stored result is replayed.", + "schema": { + "enum": [ + "true" + ], + "type": "string" + } + }, + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unsupported_parameter", + "message": "The requested parameter is not supported.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The request is unsupported or semantically invalid.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_api_key", + "message": "Invalid or expired API key.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The API credential is missing, invalid, or expired.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_found", + "message": "Response not found.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The resource does not exist or belongs to another API key.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_ready", + "message": "The requested state transition conflicts.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The requested state transition conflicts with current state.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_request_error", + "message": "Request does not match the API schema.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The JSON body or query parameters do not match the schema.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/json": { + "example": { + "error": { + "code": "rate_limit_exceeded", + "message": "The request limit was exceeded.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "A request or concurrency limit was exceeded.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "example": { + "error": { + "code": "internal_error", + "message": "The response failed unexpectedly.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response failed because of an internal error.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service_unavailable", + "message": "The service is temporarily unavailable.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "504": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_timeout", + "message": "Response timed out.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response exceeded its generation timeout.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "PublicAPIKey": [] + } + ], + "summary": "Create a stateful model response", + "tags": [ + "openai-responses" + ] + } + }, + "/v1/responses/{response_id}": { + "delete": { + "operationId": "remove_response_v1_responses__response_id__delete", + "parameters": [ + { + "in": "path", + "name": "response_id", + "required": true, + "schema": { + "title": "Response Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedResource" + } + } + }, + "description": "Successful Response", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unsupported_parameter", + "message": "The requested parameter is not supported.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The request is unsupported or semantically invalid.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_api_key", + "message": "Invalid or expired API key.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The API credential is missing, invalid, or expired.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_found", + "message": "Response not found.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The resource does not exist or belongs to another API key.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_ready", + "message": "The requested state transition conflicts.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The requested state transition conflicts with current state.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_request_error", + "message": "Request does not match the API schema.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The JSON body or query parameters do not match the schema.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/json": { + "example": { + "error": { + "code": "rate_limit_exceeded", + "message": "The request limit was exceeded.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "A request or concurrency limit was exceeded.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "example": { + "error": { + "code": "internal_error", + "message": "The response failed unexpectedly.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response failed because of an internal error.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service_unavailable", + "message": "The service is temporarily unavailable.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "504": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_timeout", + "message": "Response timed out.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response exceeded its generation timeout.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "PublicAPIKey": [] + } + ], + "summary": "Delete a stored response", + "tags": [ + "openai-responses" + ] + }, + "get": { + "operationId": "retrieve_response_v1_responses__response_id__get", + "parameters": [ + { + "in": "path", + "name": "response_id", + "required": true, + "schema": { + "title": "Response Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseObject" + } + } + }, + "description": "Successful Response", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unsupported_parameter", + "message": "The requested parameter is not supported.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The request is unsupported or semantically invalid.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_api_key", + "message": "Invalid or expired API key.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The API credential is missing, invalid, or expired.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_found", + "message": "Response not found.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The resource does not exist or belongs to another API key.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_ready", + "message": "The requested state transition conflicts.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The requested state transition conflicts with current state.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_request_error", + "message": "Request does not match the API schema.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The JSON body or query parameters do not match the schema.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/json": { + "example": { + "error": { + "code": "rate_limit_exceeded", + "message": "The request limit was exceeded.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "A request or concurrency limit was exceeded.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "example": { + "error": { + "code": "internal_error", + "message": "The response failed unexpectedly.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response failed because of an internal error.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service_unavailable", + "message": "The service is temporarily unavailable.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "504": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_timeout", + "message": "Response timed out.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response exceeded its generation timeout.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "PublicAPIKey": [] + } + ], + "summary": "Retrieve a stored response", + "tags": [ + "openai-responses" + ] + } + }, + "/v1/responses/{response_id}/cancel": { + "post": { + "operationId": "reject_response_cancellation_v1_responses__response_id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "response_id", + "required": true, + "schema": { + "title": "Response Id", + "type": "string" + } + } + ], + "responses": { + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unsupported_parameter", + "message": "The requested parameter is not supported.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The request is unsupported or semantically invalid.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_api_key", + "message": "Invalid or expired API key.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The API credential is missing, invalid, or expired.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_found", + "message": "Response not found.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The resource does not exist or belongs to another API key.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_ready", + "message": "The requested state transition conflicts.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The requested state transition conflicts with current state.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_request_error", + "message": "Request does not match the API schema.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The JSON body or query parameters do not match the schema.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/json": { + "example": { + "error": { + "code": "rate_limit_exceeded", + "message": "The request limit was exceeded.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "A request or concurrency limit was exceeded.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "example": { + "error": { + "code": "internal_error", + "message": "The response failed unexpectedly.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response failed because of an internal error.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service_unavailable", + "message": "The service is temporarily unavailable.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "504": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_timeout", + "message": "Response timed out.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response exceeded its generation timeout.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "PublicAPIKey": [] + } + ], + "summary": "Reject background cancellation in subset v1", + "tags": [ + "openai-responses" + ] + } + }, + "/v1/responses/{response_id}/input_items": { + "get": { + "operationId": "list_response_input_items_v1_responses__response_id__input_items_get", + "parameters": [ + { + "in": "path", + "name": "response_id", + "required": true, + "schema": { + "title": "Response Id", + "type": "string" + } + }, + { + "in": "query", + "name": "after", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "After" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "order", + "required": false, + "schema": { + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "title": "Order", + "type": "string" + } + }, + { + "in": "query", + "name": "include", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Include" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseInputItemList" + } + } + }, + "description": "Successful Response", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "example": { + "error": { + "code": "unsupported_parameter", + "message": "The requested parameter is not supported.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The request is unsupported or semantically invalid.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_api_key", + "message": "Invalid or expired API key.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The API credential is missing, invalid, or expired.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_found", + "message": "Response not found.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The resource does not exist or belongs to another API key.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "409": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_not_ready", + "message": "The requested state transition conflicts.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The requested state transition conflicts with current state.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "example": { + "error": { + "code": "invalid_request_error", + "message": "Request does not match the API schema.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The JSON body or query parameters do not match the schema.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/json": { + "example": { + "error": { + "code": "rate_limit_exceeded", + "message": "The request limit was exceeded.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "A request or concurrency limit was exceeded.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "500": { + "content": { + "application/json": { + "example": { + "error": { + "code": "internal_error", + "message": "The response failed unexpectedly.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response failed because of an internal error.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/json": { + "example": { + "error": { + "code": "service_unavailable", + "message": "The service is temporarily unavailable.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + }, + "504": { + "content": { + "application/json": { + "example": { + "error": { + "code": "response_timeout", + "message": "Response timed out.", + "type": "invalid_request_error" + } + }, + "schema": { + "$ref": "#/components/schemas/OpenAIErrorResponse" + } + } + }, + "description": "The response exceeded its generation timeout.", + "headers": { + "X-Request-ID": { + "description": "Request identifier generated by the server or supplied by the caller.", + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "PublicAPIKey": [] + } + ], + "summary": "List input items for a stored response", + "tags": [ + "openai-responses" + ] + } + } + } +} diff --git a/backend/db/models.py b/backend/db/models.py index 0e39fdc..55018d4 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -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() ) diff --git a/backend/migrations/versions/20260907_0025_harden_openai_responses_contract.py b/backend/migrations/versions/20260907_0025_harden_openai_responses_contract.py new file mode 100644 index 0000000..e40ae27 --- /dev/null +++ b/backend/migrations/versions/20260907_0025_harden_openai_responses_contract.py @@ -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") diff --git a/backend/requirements.txt b/backend/requirements.txt index 680c73c..d996468 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routes/openai_models.py b/backend/routes/openai_models.py new file mode 100644 index 0000000..49824f1 --- /dev/null +++ b/backend/routes/openai_models.py @@ -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, + ) diff --git a/backend/routes/openai_realtime.py b/backend/routes/openai_realtime.py index 382b2b9..990c71a 100644 --- a/backend/routes/openai_realtime.py +++ b/backend/routes/openai_realtime.py @@ -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) diff --git a/backend/routes/openai_responses.py b/backend/routes/openai_responses.py index e85595f..85215f5 100644 --- a/backend/routes/openai_responses.py +++ b/backend/routes/openai_responses.py @@ -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) diff --git a/backend/scripts/export_contracts.py b/backend/scripts/export_contracts.py new file mode 100644 index 0000000..f4dd139 --- /dev/null +++ b/backend/scripts/export_contracts.py @@ -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() diff --git a/backend/scripts/run_tests.py b/backend/scripts/run_tests.py new file mode 100644 index 0000000..81c567b --- /dev/null +++ b/backend/scripts/run_tests.py @@ -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() diff --git a/backend/services/openai_api.py b/backend/services/openai_api.py new file mode 100644 index 0000000..992b01d --- /dev/null +++ b/backend/services/openai_api.py @@ -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) diff --git a/backend/services/openai_realtime/bridge.py b/backend/services/openai_realtime/bridge.py index d9e6aac..250cc69 100644 --- a/backend/services/openai_realtime/bridge.py +++ b/backend/services/openai_realtime/bridge.py @@ -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 diff --git a/backend/services/openai_realtime/events.py b/backend/services/openai_realtime/events.py index 50e5a44..2092357 100644 --- a/backend/services/openai_realtime/events.py +++ b/backend/services/openai_realtime/events.py @@ -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: diff --git a/backend/services/openai_realtime/schemas.py b/backend/services/openai_realtime/schemas.py new file mode 100644 index 0000000..a53d332 --- /dev/null +++ b/backend/services/openai_realtime/schemas.py @@ -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 diff --git a/backend/services/openai_responses/protocol.py b/backend/services/openai_responses/protocol.py index 2be2937..89189b1 100644 --- a/backend/services/openai_responses/protocol.py +++ b/backend/services/openai_responses/protocol.py @@ -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), } diff --git a/backend/services/openai_responses/runner.py b/backend/services/openai_responses/runner.py index e625bfd..3030c81 100644 --- a/backend/services/openai_responses/runner.py +++ b/backend/services/openai_responses/runner.py @@ -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(): diff --git a/backend/services/openai_responses/schemas.py b/backend/services/openai_responses/schemas.py new file mode 100644 index 0000000..dd82721 --- /dev/null +++ b/backend/services/openai_responses/schemas.py @@ -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] diff --git a/backend/services/openai_responses/state.py b/backend/services/openai_responses/state.py index e41eec8..16d1ecd 100644 --- a/backend/services/openai_responses/state.py +++ b/backend/services/openai_responses/state.py @@ -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) diff --git a/backend/services/openai_responses/wire.py b/backend/services/openai_responses/wire.py new file mode 100644 index 0000000..d5a0086 --- /dev/null +++ b/backend/services/openai_responses/wire.py @@ -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 diff --git a/backend/tests/test_openai_response_state_contract.py b/backend/tests/test_openai_response_state_contract.py new file mode 100644 index 0000000..b56e493 --- /dev/null +++ b/backend/tests/test_openai_response_state_contract.py @@ -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() diff --git a/backend/tests/test_openai_responses.py b/backend/tests/test_openai_responses.py index d2daaad..5e463a5 100644 --- a/backend/tests/test_openai_responses.py +++ b/backend/tests/test_openai_responses.py @@ -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): diff --git a/backend/tests/test_openai_sdk_contract.py b/backend/tests/test_openai_sdk_contract.py new file mode 100644 index 0000000..57c8471 --- /dev/null +++ b/backend/tests/test_openai_sdk_contract.py @@ -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() diff --git a/backend/tests/test_public_contract_snapshots.py b/backend/tests/test_public_contract_snapshots.py new file mode 100644 index 0000000..f942989 --- /dev/null +++ b/backend/tests/test_public_contract_snapshots.py @@ -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() diff --git a/docs-developer/development-plan.mdx b/docs-developer/development-plan.mdx new file mode 100644 index 0000000..27cb252 --- /dev/null +++ b/docs-developer/development-plan.mdx @@ -0,0 +1,361 @@ +--- +title: 单租户生产化开发计划 +description: 以标准接口、实时可观测性、运行可靠性和全链路质量门禁为主线的建设计划。 +icon: list-checks +--- + +# 单租户生产化开发计划 + +## 目标 + +本计划把当前平台从“功能完整的单租户 MVP”推进到“可以稳定对外提供服务的单租户生产平台”。建设顺序固定为: + +1. 标准接口契约; +2. 实时可观测性与 SLO; +3. 生产运行可靠性; +4. 全链路质量门禁。 + +顺序不能颠倒:先确定平台对调用方承诺的行为,再测量真实质量,然后根据数据加固运行时,最后用自动化门禁持续证明质量。 + +## 范围 + +本计划包含: + +- OpenAI-compatible Responses 和 Realtime 公开接口; +- HTTP、SSE、WebSocket 和 WebRTC 的协议契约; +- Pipecat Pipeline、Provider、工具、知识库和后台任务的指标与追踪; +- 单实例优先的生产部署、优雅停机、任务恢复和资源保护; +- 协议、浏览器媒体链路、弱网、故障和负载测试。 + +本计划暂不包含: + +- 多用户、组织、工作空间和 RBAC; +- `/v1/chat/completions` 兼容层; +- 完整实现 OpenAI Responses 的所有可选能力; +- SIP、跨区域部署和 Realtime 断线续传; +- 助手草稿、发布版本和环境晋级体系。 + + +即使暂不建设完整权限体系,公网部署前仍必须移除默认凭证、分离签名密钥、启用 TLS,并设置基本的请求与并发限制。 + + +## 当前基线 + +- `POST /v1/responses` 和 `GET /v1/responses/{response_id}` 已支持持久化状态、`previous_response_id`、SSE 和 Client Tool 续传。 +- `/v1/realtime/client_secrets`、`/v1/realtime/calls` 和 `/v1/realtime` WebSocket 已支持标准事件与项目扩展事件。 +- Realtime 已支持 WebRTC、WebSocket、Server VAD、PTT、打断、函数调用和条件视觉输入。 +- 后端现有 342 个自动化测试,并通过统一入口处理本地 NLTK 运行环境。 +- 前端可以通过构建和 lint,已增加官方 SDK 契约测试,但尚无系统性的组件测试和浏览器端到端测试。 +- Responses 已启用 Provider token usage 采集;延迟等 Pipecat metrics 尚未启用,健康检查仍只有进程存活语义。 +- 知识库处理、通话后分析、Webhook 和批量测试仍有进程内任务或 Worker。 + +## 里程碑一:标准接口契约 + +### 目标 + +让第三方调用方只依赖公开文档、OpenAPI 和标准 SDK 即可完成接入,并清楚知道哪些 OpenAI 能力受支持、哪些能力被平台锁定或尚未实现。 + +### 1.1 建立兼容性规范 + +- [x] 将公开协议命名为 `OpenAI-compatible subset v1`。 +- [x] 为 Responses 请求字段、返回对象、SSE 事件和错误码维护兼容矩阵。 +- [x] 为 Realtime 客户端事件、服务端事件、传输限制和扩展事件维护兼容矩阵。 +- [x] 区分三种字段状态:支持、平台锁定、不支持。 +- [x] 为 `x.interactive_media.*` 扩展增加独立版本号和能力协商说明。 +- [x] 规定新增字段向后兼容、废弃字段通知和破坏性变更升级规则。 + +### 1.2 补全公开 API Schema + +- [x] 为 Responses create、retrieve 和错误对象建立 Pydantic 模型。 +- [x] 使用判别联合描述 `message`、`function_call` 和 `function_call_output`。 +- [x] 在 OpenAPI 中声明 JSON、multipart/form-data、application/sdp 和 text/event-stream。 +- [x] 为 Realtime JSON 事件维护可校验的 JSON Schema。 +- [x] 为所有公开响应声明状态码、错误结构和示例。 +- [x] 生成并提交稳定的 OpenAPI snapshot,CI 检查未审查的契约变化。 + +### 1.3 完善 Responses 资源接口 + +- [x] 增加 `GET /v1/responses/{response_id}/input_items`,支持稳定排序和分页。 +- [x] 增加 `DELETE /v1/responses/{response_id}`。 +- [x] 删除 Response 时使用逻辑删除或内部 tombstone,不能破坏已有子 Response 的祖先链。 +- [x] 增加 `Idempotency-Key`:同 Key、同请求返回原结果;同 Key、不同请求返回冲突。 +- [x] Provider 可以提供 Token 统计时返回真实 `usage`;无法提供时保持 `null` 并写入兼容说明。 +- [x] 暂不实现 background 时,明确拒绝 `/cancel`,而不是静默接受。 + +### 1.4 增加模型发现与统一错误 + +- [x] 增加 `GET /v1/models`,列出当前可以通过公开接口调用的助手。 +- [x] 第一版继续返回 `assistant:asst_xxx`;响应结构为未来稳定助手别名保留字段。 +- [x] 所有公开 HTTP 错误统一为 OpenAI 风格的 `error` 对象。 +- [x] 所有 HTTP 响应返回或透传 `X-Request-ID`。 +- [x] Realtime `error.event_id` 必须关联原客户端事件。 +- [x] 401、404、409、422、429、503 的使用规则写入协议文档。 + +### 1.5 SDK 契约测试 + +- [x] 使用官方 OpenAI Python SDK 测试 Responses 非流式、多轮和 SSE。 +- [x] 使用官方 OpenAI JavaScript SDK 测试相同场景。 +- [x] 测试 Client Tool 调用及 `function_call_output` 续传。 +- [x] 测试跨 API Key 隐藏、重复幂等键和不支持字段。 +- [x] 对 Realtime WebSocket 事件运行 JSON Schema 与事件顺序测试。 + +### 验收标准 + +- OpenAPI 中所有公开 HTTP 接口都有请求体、响应体和错误 schema。 +- 官方 Python 和 JavaScript SDK 可以通过自定义 `base_url` 完成三轮 Responses 对话。 +- 非流式结果、SSE 最终结果和 GET 查询结果语义一致。 +- 公开兼容矩阵与代码自动化测试一一对应。 +- 调用方不阅读项目源码也能完成首次接入。 + +## 里程碑二:实时可观测性与 SLO + +### 目标 + +能够回答“慢在哪里、错在哪里、影响了多少会话”,并用可量化指标定义实时对话质量。 + +### 2.1 建立统一关联标识 + +每次执行使用以下层级: + +```text +request_id + └─ session_id / response_id + └─ conversation_id + └─ turn_id + ├─ asr span + ├─ llm span + ├─ tts span + ├─ knowledge span + └─ tool span +``` + +- [ ] 使用 `contextvars` 在异步任务中传播关联标识。 +- [ ] HTTP、SSE、WebSocket、WebRTC、后台任务和 Webhook 使用同一套标识命名。 +- [ ] 日志中输出关联标识,但不把提示词、转写、密钥或动态变量作为默认日志字段。 +- [ ] 指标标签禁止使用 response ID、session ID 等高基数字段。 + +### 2.2 接入 Metrics 和 Tracing + +- [ ] 启用 Pipecat Pipeline metrics。 +- [ ] 接入 Prometheus 指标端点。 +- [ ] 接入 OpenTelemetry SDK,并允许通过 OTLP 环境变量配置 exporter。 +- [ ] 为 FastAPI、HTTP Provider 调用和 PostgreSQL 增加基础自动插桩。 +- [ ] 为 Pipeline 阶段、工具、知识库、SSE 和后台任务增加业务 span。 +- [ ] exporter 不可用时不得阻塞或终止实时对话。 + +### 2.3 第一批指标 + +| 指标 | 类型 | 推荐标签 | +| --- | --- | --- | +| 活跃 Realtime 会话 | Gauge | channel、runtime_mode | +| 会话建立和结束 | Counter | channel、status、reason | +| 用户停说到首音频 | Histogram | runtime_mode、provider | +| ASR final 延迟 | Histogram | interface_type、status | +| LLM 首 Token 延迟 | Histogram | interface_type、status | +| TTS 首音频延迟 | Histogram | interface_type、status | +| 打断处理延迟 | Histogram | channel、status | +| 工具调用延迟 | Histogram | tool_type、status | +| 知识库检索延迟 | Histogram | mode、status | +| Responses 请求 | Counter/Histogram | stream、status | +| 后台任务 | Counter/Histogram | kind、status | + +- [ ] 统一定义成功、取消、客户端断开、超时和内部失败状态。 +- [ ] Provider 错误按鉴权、限流、超时、网络和服务端错误分类。 +- [ ] WebRTC 首版从服务端可见统计开始;浏览器 `getStats()` 上报作为后续增强。 + +### 2.4 仪表盘与初始 SLO + +- [ ] 建立 Realtime 总览、Provider、Responses、工具与后台任务仪表盘。 +- [ ] 至少展示 p50、p95、p99 和最近错误样本。 +- [ ] 先采集基线,再确认正式阈值。 +- [ ] 为会话建立失败率、首音频延迟、Provider 错误率和任务积压设置告警。 + +初始候选目标: + +| 项目 | 候选目标 | +| --- | --- | +| Realtime 会话建立成功率 | ≥ 99.5% | +| 用户停说到首音频 | p95 ≤ 2 秒 | +| 服务端打断处理 | p95 ≤ 250 ms | +| 平台自身导致的 turn 失败率 | ≤ 0.5% | +| Responses 非流式成功率 | ≥ 99.5% | + +正式 SLO 必须在目标部署环境和真实 Provider 上取得基线后确认。 + +### 验收标准 + +- 可以从一次失败的会话追踪到具体 Provider、工具或存储阶段。 +- 仪表盘能展示首音频延迟、错误率、活跃会话和任务积压。 +- 关闭监控后端不会影响对话主链路。 +- 日志和指标不泄漏用户内容或密钥。 + +## 里程碑三:生产运行可靠性 + +### 目标 + +在单租户、单区域、单个 Realtime API 进程优先的约束下,实现可恢复、可排空、有限流和可备份的生产运行方式。 + +### 3.1 Realtime 进程生命周期 + +- [ ] 增加服务状态:starting、ready、draining、stopped。 +- [ ] draining 后停止签发 Client Secret,并拒绝新 Calls 和 WebSocket 会话。 +- [ ] 已建立会话在可配置的宽限期内继续运行。 +- [ ] 宽限期结束后取消剩余 Pipeline,并持久化明确终态。 +- [ ] 统一 WebRTC、WebSocket、RTVI 和 Responses 的关闭及取消路径。 +- [ ] 为 Pipeline 启动、关闭和资源释放增加超时保护。 + +Realtime Pipeline 与媒体连接绑定,继续保留在 API 进程内,不迁移到普通任务队列。 + +### 3.2 健康检查和资源保护 + +- [ ] 增加 `/health/live`,只判断事件循环和进程是否存活。 +- [ ] 增加 `/health/ready`,检查数据库、迁移版本、必要配置和对象存储。 +- [ ] readiness 失败时不接收新会话,但 liveness 不应因此触发无限重启。 +- [ ] 设置全局活跃会话上限和每 API Key 并发上限。 +- [ ] 为请求体、音频缓冲、SSE 队列和工具结果设置大小上限。 +- [ ] 数据库连接池、HTTP 连接池和线程池使用显式上限。 +- [ ] 达到容量上限时返回可识别的 429 或 503。 + +### 3.3 Provider 可靠性策略 + +- [ ] 统一 Provider 错误分类和超时配置。 +- [ ] 只有在尚未向客户端输出内容时才允许自动重试。 +- [ ] 流式输出开始后禁止透明重试,避免重复文字、音频或工具调用。 +- [ ] 对新会话实现轻量熔断和恢复探测。 +- [ ] 模型资源测试与生产请求共享同一套连接和错误分类逻辑。 +- [ ] 备用 Provider 或模型作为后续可选能力,不在第一批强制实现。 + +### 3.4 迁移后台任务 + +第一批迁移以下任务: + +- 知识库文档解析与向量化; +- 通话后分析; +- Webhook 投递; +- 批量测试和 Prompt 改进。 + +实现约束: + +- [ ] 使用 Redis + ARQ 作为单租户 MVP 队列。 +- [ ] PostgreSQL 保存任务业务状态,Redis 只负责投递、租约和重试。 +- [ ] 每类任务都有幂等键、最大重试次数和明确终态。 +- [ ] Worker 崩溃后任务可以重新领取。 +- [ ] 重复执行不能重复发送 Webhook 或写入重复结果。 +- [ ] API 能查询任务状态,服务启动不再直接恢复和执行全部后台工作。 + +### 3.5 生产部署和恢复 + +- [ ] 增加生产 API 镜像,移除 `--reload`。 +- [ ] 前端使用 `next build` 与 `next start` 或静态生产部署。 +- [ ] 数据库迁移作为独立部署步骤,不能由多个 API 实例同时执行。 +- [ ] 为 PostgreSQL 和对象存储编写备份、恢复和校验步骤。 +- [ ] 设置日志轮转、磁盘水位和录音/Response 保留策略。 +- [ ] 完成一次从备份恢复到新环境的演练。 + +### 验收标准 + +- 服务进入 draining 后不接收新会话,已有会话可以正常结束或在超时后被正确取消。 +- Worker 被强制终止后,未完成任务可以恢复且不会重复产生副作用。 +- Provider 超时不会无限占用 Pipeline 或连接。 +- 数据库或对象存储不可用时 readiness 会失败并给出可诊断原因。 +- 生产部署不运行开发热更新服务。 + +## 里程碑四:全链路质量门禁 + +### 目标 + +把协议正确性、实时体验和故障恢复变成每次变更都能重复验证的发布条件。 + +### 4.1 测试分层 + +| 层级 | 运行时机 | 内容 | +| --- | --- | --- | +| 单元和组件测试 | 每次提交 | 协议解析、状态机、工具、工作流、指标 | +| 契约测试 | 每次提交 | OpenAPI、JSON Schema、Python/JS SDK | +| 服务集成测试 | 每次提交 | PostgreSQL、Redis、对象存储、Fake Provider | +| 浏览器媒体测试 | 主分支或发布前 | WebRTC、假麦克风、DataChannel、打断 | +| 基础设施测试 | 每日或发布前 | TURN、弱网、故障注入 | +| 真实 Provider smoke | 每日或发布前 | 限量 ASR、LLM、TTS、Realtime 调用 | +| 负载和长稳测试 | 发布前 | 并发、容量、内存、连接和任务泄漏 | + +### 4.2 每次提交门禁 + +- [ ] 提供一个无需隐藏环境变量的后端测试命令。 +- [ ] 运行后端全部测试、前端 lint 和 production build。 +- [ ] 从空数据库执行所有 Alembic migrations。 +- [ ] 校验 OpenAPI snapshot 和 Realtime JSON Schema。 +- [ ] 使用 Fake Provider 完成 Responses 三轮对话、分支和 Client Tool 续传。 +- [ ] 测试 SSE 断开、Pipeline 失败、超时和取消后的数据库终态。 +- [ ] 测试 Worker 幂等、重试和任务恢复。 + +### 4.3 浏览器和媒体链路 + +- [ ] 使用 Playwright Chromium 假麦克风输入固定音频文件。 +- [ ] 验证 WebRTC SDP、远端音频轨道和 `oai-events` DataChannel。 +- [ ] 验证 Server VAD、PTT、打断和 Client Tool。 +- [ ] 使用固定 PCM fixture 测试 Realtime WebSocket。 +- [ ] 在独立环境中强制只走 TURN,避免测试误用直连路径。 +- [ ] 保存失败时的浏览器日志、协议事件和服务端 trace。 + +### 4.4 弱网、故障和负载 + +- [ ] 使用 Linux `tc netem` 注入延迟、抖动、丢包和短时断网。 +- [ ] 注入 ASR、LLM、TTS、工具、数据库、Redis 和对象存储超时。 +- [ ] 验证客户端断开后 Pipeline 与资源及时释放。 +- [ ] 对 Responses HTTP/SSE 和 Realtime WebSocket 运行并发测试。 +- [ ] 使用 aiortc 测试客户端运行 WebRTC 并发场景。 +- [ ] 运行 30 至 60 分钟长连接 soak test,观察内存、Task 和连接数量。 + +### 4.5 发布判定 + +一次生产发布至少满足: + +- [ ] 单元、契约、集成和浏览器关键路径全部通过。 +- [ ] 没有未审查的公开协议变化。 +- [ ] p95 首音频延迟未超过允许回归范围。 +- [ ] 错误率、超时率和异常断开率未超过阈值。 +- [ ] 没有持续增长的 Pipeline、asyncio Task、数据库连接或 WebRTC peer。 +- [ ] 备份恢复和 Worker 恢复机制在当前版本仍然有效。 + +## 推荐交付批次 + +### 批次 A:契约与工程入口 + +- 修复统一测试命令; +- 建立后端、前端和 migration CI; +- 为 Responses 增加 Pydantic/OpenAPI schema; +- 增加统一错误对象、Request ID 和 OpenAPI snapshot; +- 增加 Python SDK Responses 契约测试。 + +### 批次 B:Realtime 契约与观测基础 + +- 建立 Realtime 事件 JSON Schema; +- 增加 JavaScript SDK 和 WebSocket 契约测试; +- 建立关联 ID 和结构化日志; +- 接入 Prometheus 与 OpenTelemetry; +- 输出第一版运行基线仪表盘。 + +### 批次 C:生命周期与 Worker + +- 增加 live、ready 和 draining; +- 增加会话、缓冲和连接池上限; +- 统一 Provider 超时和重试边界; +- 引入 ARQ Worker 并逐类迁移后台任务; +- 增加生产镜像与备份恢复说明。 + +### 批次 D:全链路发布门禁 + +- 建立 PostgreSQL、Redis、对象存储和 Fake Provider 集成环境; +- 增加 Playwright WebRTC 与 PCM WebSocket 测试; +- 增加 TURN、弱网、故障注入和真实 Provider smoke; +- 建立负载、长稳测试和发布阈值。 + +## 总体验收 + +完成本计划后,平台应满足以下定义: + +1. 对外接口有明确、可生成、可测试的稳定契约; +2. 每个请求、会话和对话轮次都可以被测量和追踪; +3. 服务、Provider 或 Worker 故障不会留下无法恢复的模糊状态; +4. 发布前可以自动证明标准接口、实时媒体链路和核心 SLO 没有回归; +5. 无需引入多用户体系,也能作为单租户生产服务安全、稳定地运行。 diff --git a/docs-developer/docs.json b/docs-developer/docs.json index c9a823c..b19bdc7 100644 --- a/docs-developer/docs.json +++ b/docs-developer/docs.json @@ -46,7 +46,11 @@ "groups": [ { "group": "开始与对接", - "pages": ["index", "integrations", "tool-resources", "responses-api", "realtime-protocol"] + "pages": ["index", "integrations", "tool-resources", "openai-compatibility", "responses-api", "realtime-protocol"] + }, + { + "group": "开发与质量", + "pages": ["development-plan"] } ] } diff --git a/docs-developer/openai-compatibility.mdx b/docs-developer/openai-compatibility.mdx new file mode 100644 index 0000000..c973378 --- /dev/null +++ b/docs-developer/openai-compatibility.mdx @@ -0,0 +1,149 @@ +--- +title: OpenAI 兼容范围 +description: OpenAI-compatible subset v1 的字段、事件、错误与版本兼容矩阵。 +icon: brackets +--- + +# OpenAI-compatible subset v1 + +平台公开协议命名为 **OpenAI-compatible subset v1**。它兼容 OpenAI Responses 与 Realtime 的核心调用方式,但只承诺本页列出的子集;没有列为“支持”的 OpenAI 字段,不能视为已经实现。 + +字段分为三种状态: + +| 状态 | 含义 | +| --- | --- | +| 支持 | 字段会被校验并产生文档描述的效果 | +| 平台锁定 | 能力存在,但值来自助手配置;调用方提交时返回 `immutable_configuration` 或 `immutable_session_field` | +| 不支持 | 当前版本不实现;显式提交时返回 `unsupported_parameter`、`unsupported_operation` 或 `invalid_event` | + +## HTTP 资源 + +| 接口 | 状态 | 说明 | +| --- | --- | --- | +| `GET /v1/models` | 支持 | 返回可通过公开协议调用的 `assistant:asst_xxx` | +| `POST /v1/responses` | 支持 | 非流式 JSON 或 Responses SSE | +| `GET /v1/responses/{id}` | 支持 | 只允许创建它的长期 API Key 查询 | +| `GET /v1/responses/{id}/input_items` | 支持 | `after`、`limit`、`order` 游标分页 | +| `DELETE /v1/responses/{id}` | 支持 | 逻辑删除;既有子 Response 的祖先链仍可恢复 | +| `POST /v1/responses/{id}/cancel` | 不支持 | subset v1 不支持 background,固定返回 `unsupported_operation` | +| `POST /v1/realtime/client_secrets` | 支持 | 使用长期 Key 签发短期、绑定助手的令牌 | +| `POST /v1/realtime/calls` | 支持 | `multipart/form-data` 或原始 `application/sdp` | +| `GET /v1/realtime` WebSocket upgrade | 支持 | 服务端或自定义客户端 Realtime 连接 | +| `/v1/chat/completions` | 不支持 | 不提供旧 Chat Completions 兼容层 | + +## Responses 请求字段 + +| 字段 | 状态 | subset v1 行为 | +| --- | --- | --- | +| `model` | 支持 | 首轮必填,格式为 `assistant:asst_xxx`;后续可继承 | +| `input` 字符串 | 支持 | 转换为一条 `user` 文本消息 | +| `input[].message` | 支持 | 仅文本 `input_text` / `output_text` | +| `input[].function_call_output` | 支持 | 只能解析父链尚未完成的 Client Tool call ID | +| `previous_response_id` | 支持 | 恢复祖先上下文,允许从同一父节点分支 | +| `stream` | 支持 | `false` 返回 JSON;`true` 返回 SSE | +| `store` | 支持但固定 | 只能省略或为 `true` | +| `x_interactive_media.variables` | 支持 | 首轮赋值,后续继承并增量覆盖 | +| `instructions` | 平台锁定 | 来自助手配置 | +| `tools`、`tool_choice` | 平台锁定 | 来自助手及工具绑定 | +| `background`、`conversation` | 不支持 | 状态只通过 `previous_response_id` 管理 | +| `include`、`reasoning`、`text` | 不支持 | 显式拒绝,不静默忽略 | +| `temperature`、`top_p`、`truncation` | 不支持 | 由底层助手和模型资源控制 | +| 图片、文件输入 | 不支持 | 文本接口只接受文本;实时图片走 Realtime item | + +Response 返回标准 `response` 对象。`output` 当前可能包含 `message` 或 `function_call`。如果底层 Provider 能稳定提供 token 统计,`usage` 返回真实值;当前无法取得时明确返回 `null`,不会估算。 + +## Responses SSE 事件 + +| 事件 | 状态 | 顺序约束 | +| --- | --- | --- | +| `response.created` | 支持 | 第一个事件 | +| `response.in_progress` | 支持 | 紧随 created | +| `response.output_item.added` / `done` | 支持 | 每个输出项各一组 | +| `response.content_part.added` / `done` | 支持 | 文本输出项使用 | +| `response.output_text.delta` / `done` | 支持 | delta 为零到多个,done 恰好一个 | +| `response.function_call_arguments.delta` / `done` | 支持 | Client Tool 参数使用;done 恰好一个 | +| `response.completed` | 支持 | 正常终态,最后一个事件 | +| `error` + `response.failed` | 支持 | 失败终态 | + +每个事件都有从 `0` 开始单调递增的 `sequence_number`。流式终态、同一 Response 的非流式结果和随后 GET 的资源语义一致。 + +## Realtime 客户端事件 + +以下客户端事件属于 Core 支持范围: + +- `session.update` +- `conversation.item.create` +- `conversation.item.truncate` +- `input_audio_buffer.append` +- `input_audio_buffer.commit` +- `input_audio_buffer.clear` +- `output_audio_buffer.clear` +- `response.create` +- `response.cancel` + +会话中的 `model`、`instructions`、`voice`、`tools` 和 `tool_choice` 为平台锁定字段。`output_modalities` 与 Server VAD / PTT 参数可更新。WebSocket 支持 Base64 PCM16 音频;WebRTC 音频走媒体轨道,事件走 `oai-events` DataChannel。连续视频只通过协商后的 WebRTC video track 提供。 + +服务端事件的完整名称与最小 envelope 由仓库中的 `backend/contracts/openai-realtime-server-events.schema.json` 定义;客户端事件由 `backend/contracts/openai-realtime-client-events.schema.json` 定义。 + +## Interactive Media Extensions v1 + +所有扩展事件都使用 `x.interactive_media.*` 命名空间,并独立版本化为字符串 `"1"`。 + +客户端先协商能力: + +```json +{ + "type": "x.interactive_media.capabilities.update", + "event_id": "evt_caps_001", + "version": "1", + "capabilities": ["dynamic_variables", "workflow_events"] +} +``` + +服务端以 `x.interactive_media.capabilities.updated` 返回 `capabilities`、`rejected` 和实际 `version`。只有协商成功后,服务端才发送相应扩展事件。当前能力为: + +| capability | 作用 | +| --- | --- | +| `dynamic_variables` | 运行中更新声明过的动态变量 | +| `workflow_events` | 接收节点、业务事件、变量和错误事件 | +| `handoff` | 接收人工接管和通话结束事件 | +| `video_track` | 协商项目扩展的 WebRTC 视频轨道 | + +扩展事件缺少版本或使用未知版本时返回 `invalid_event`;未知 capability 会出现在 `rejected` 中,不会被静默启用。 + +## 错误与状态码 + +所有公开 HTTP 错误统一返回: + +```json +{ + "error": { + "type": "invalid_request_error", + "code": "response_not_ready", + "message": "Previous response is in_progress", + "param": "previous_response_id" + } +} +``` + +| HTTP 状态 | 使用规则 | +| --- | --- | +| `400` | schema 合法但字段不支持、平台锁定或业务语义无效 | +| `401` | Bearer Key 缺失、无效、过期,或令牌类型不适用于接口 | +| `404` | 资源不存在、已删除或属于另一个 API Key | +| `409` | 父响应未结束、幂等键冲突或资源状态冲突 | +| `422` | JSON、路径或查询参数不符合公开 schema | +| `429` | 请求、并发或容量限制;当前为预留标准状态 | +| `500` | 运行中不可恢复的内部失败 | +| `503` | 服务暂时不能接收请求;当前为预留标准状态 | +| `504` | Responses 生成超过服务端超时 | + +每个 HTTP 响应都带 `X-Request-ID`。调用方传入符合格式的 ID 时服务端透传,否则生成 `req_...`。Realtime `error` 事件中的 `error.event_id` 指向导致错误的客户端 `event_id`。 + +## 兼容性规则 + +- subset v1 内新增可选字段、可选事件字段和新错误码属于向后兼容变更。 +- 新增必须由调用方主动使用的扩展 capability 属于向后兼容变更。 +- 删除字段、改变字段含义、改变既有事件顺序或把可选字段改为必填,必须升级协议主版本。 +- 平台锁定或不支持的字段转为支持,可以在 v1 内发布,但必须同步 schema、兼容矩阵、示例和 SDK 契约测试。 +- 废弃项至少在开发者文档中标记一个发布周期;破坏性变更不得在原版本中静默上线。 diff --git a/docs-developer/realtime-protocol.mdx b/docs-developer/realtime-protocol.mdx index fde3be4..b94eb48 100644 --- a/docs-developer/realtime-protocol.mdx +++ b/docs-developer/realtime-protocol.mdx @@ -6,7 +6,7 @@ icon: radio # Realtime 兼容协议设计 -> 当前实现版本为 `Interactive Media Realtime Extensions v1`。OpenAI 兼容接口与现有 `/api/webrtc/offer`、`/ws/voice`、`/ws/stream`、RTVI 消息并行提供;RTVI 不是待删除的迁移接口。 +> 当前公开协议为 `OpenAI-compatible subset v1`,扩展版本为 `Interactive Media Realtime Extensions v1`。完整兼容矩阵见 [OpenAI 兼容范围](/openai-compatibility)。OpenAI 兼容接口与现有 `/api/webrtc/offer`、`/ws/voice`、`/ws/stream`、RTVI 消息并行提供;RTVI 不是待删除的迁移接口。 ## 设计目标 @@ -40,6 +40,8 @@ Realtime 协议适配层 普通兼容客户端不声明扩展能力时,服务端不会发送 `x.interactive_media.*` 事件。它仍可使用音频、文本、图片、转写、回复流、打断和函数调用,但不会获得工作流节点、动态变量、连续视频等项目专属状态。 +客户端和服务端事件的机器可校验版本分别保存在 `backend/contracts/openai-realtime-client-events.schema.json` 与 `backend/contracts/openai-realtime-server-events.schema.json`。所有服务端事件含唯一 `event_id`;错误事件中的 `error.event_id` 指向触发错误的客户端事件。 + ## 接口概览 假设服务地址为 `https://api.example.com`: @@ -507,6 +509,7 @@ Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或 { "event_id": "evt_capabilities_001", "type": "x.interactive_media.capabilities.update", + "version": "1", "capabilities": [ "workflow_events", "dynamic_variables", @@ -522,6 +525,7 @@ Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或 { "event_id": "evt_server_001", "type": "x.interactive_media.capabilities.updated", + "version": "1", "capabilities": [ "workflow_events", "dynamic_variables", @@ -532,7 +536,7 @@ Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或 } ``` -服务端只发送已接受能力对应的扩展事件。扩展版本与 OpenAI Realtime Core 独立演进。 +服务端只发送已接受能力对应的扩展事件。所有扩展事件使用字符串 `version: "1"`;扩展版本与 OpenAI Realtime Core 独立演进。 ### 扩展事件目录 @@ -555,6 +559,7 @@ Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或 { "event_id": "evt_variables_001", "type": "x.interactive_media.session.variables.update", + "version": "1", "update_id": "update_001", "variables": { "user_name": "王先生", @@ -567,6 +572,7 @@ Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或 { "event_id": "evt_server_002", "type": "x.interactive_media.session.variables.updated", + "version": "1", "update_id": "update_001", "status": "accepted" } @@ -578,6 +584,7 @@ Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或 { "event_id": "evt_server_003", "type": "x.interactive_media.workflow.node_active", + "version": "1", "nodeId": "collect_materials" } ``` @@ -588,6 +595,7 @@ Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或 { "event_id": "evt_server_004", "type": "x.interactive_media.call.ended", + "version": "1", "reason": "assistant_completed" } ``` @@ -625,13 +633,13 @@ Qwen Realtime provider 只允许在 provider 会话首次配置前选择 PTT 或 | code | 含义 | | --- | --- | -| `authentication_failed` | API Key 或临时令牌无效 | -| `assistant_not_found` | 助手不存在、未发布或不可访问 | -| `session_field_locked` | 客户端尝试覆盖服务端锁定配置 | -| `unsupported_event` | 当前协议版本不支持该事件 | -| `capability_not_enabled` | 未协商或无权使用某项扩展 | -| `response_in_progress` | 已有活动回复且当前请求不能并发执行 | -| `tool_result_timeout` | Client 工具未在规定时间内返回 | +| `invalid_api_key` | API Key 或临时令牌无效 | +| `invalid_model` | 助手不存在、格式错误或不适用于当前运行时 | +| `immutable_session_field` | 客户端尝试覆盖服务端锁定配置 | +| `invalid_event` | 事件名或事件字段不符合当前 schema | +| `extension_not_negotiated` | 使用了尚未协商的扩展能力 | +| `input_audio_buffer_not_committed` | 手动音频缓冲尚未提交 | +| `input_audio_buffer_too_large` | 音频超过单次缓冲上限 | | `internal_error` | 运行时出现不可恢复错误 | 错误只终止相关事件;只有鉴权失败、协议严重错误或运行时不可恢复时才关闭整个连接。 diff --git a/docs-developer/responses-api.mdx b/docs-developer/responses-api.mdx index 49f6ebe..51fa6cd 100644 --- a/docs-developer/responses-api.mdx +++ b/docs-developer/responses-api.mdx @@ -5,10 +5,19 @@ description: 使用 OpenAI Responses 风格的 HTTP 与 SSE 接口调用平台 # Stateful Responses 文本接口 -`POST /v1/responses` 为 Pipeline 助手提供文本调用。服务端保存每轮输入和输出,后续请求通过 `previous_response_id` 恢复上下文。语音、视频和长连接交互继续使用 `/v1/realtime`。 +`POST /v1/responses` 是 **OpenAI-compatible subset v1** 的有状态文本接口。服务端保存每轮输入和输出,后续请求通过 `previous_response_id` 恢复上下文。语音、视频和长连接交互继续使用 `/v1/realtime`。字段和事件边界见 [OpenAI 兼容范围](/openai-compatibility)。 接口使用后台创建的长期 `sk-rt-...` API Key。短期 `ek-rt-...` Client Secret 不能调用文本接口。 +## 发现可用助手 + +```bash +curl "https://api.example.com/v1/models" \ + -H "Authorization: Bearer $REALTIME_API_KEY" +``` + +返回对象中的 `id` 可直接作为 Responses 或 Realtime 的 `model`。`capabilities` 表明该助手支持 `responses`、`realtime` 或两者。 + ## 首轮请求 ```bash @@ -65,6 +74,54 @@ curl "https://api.example.com/v1/responses/resp_xxx" \ -H "Authorization: Bearer $REALTIME_API_KEY" ``` +## 官方 SDK + +Python: + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-rt-...", + base_url="https://api.example.com/v1", +) + +first = client.responses.create( + model="assistant:asst_xxx", + input="第一轮", +) +second = client.responses.create( + previous_response_id=first.id, + input="第二轮", +) +third = client.responses.create( + previous_response_id=second.id, + input="第三轮", +) +print(third.output_text) +``` + +JavaScript: + +```js +import OpenAI from "openai"; + +const client = new OpenAI({ + apiKey: "sk-rt-...", + baseURL: "https://api.example.com/v1", +}); + +const first = await client.responses.create({ + model: "assistant:asst_xxx", + input: "第一轮", +}); +const second = await client.responses.create({ + previous_response_id: first.id, + input: "第二轮", +}); +console.log(second.output_text); +``` + ## SSE 流式输出 设置 `stream: true` 后返回 `text/event-stream`: @@ -77,7 +134,37 @@ curl "https://api.example.com/v1/responses/resp_xxx" \ } ``` -主要事件依次为 `response.created`、`response.in_progress`、`response.output_text.delta`、`response.output_text.done` 和 `response.completed`。连接中断会取消运行,并把 Response 标记为 `cancelled`。 +文本输出的主要事件依次为 `response.created`、`response.in_progress`、`response.output_text.delta`、`response.output_text.done` 和 `response.completed`。Client Tool 参数通过 `response.function_call_arguments.delta` 与 `response.function_call_arguments.done` 返回。连接中断会取消运行,并把 Response 标记为 `cancelled`。 + +每个 SSE 事件都有连续的 `sequence_number`。Python SDK 可直接迭代: + +```python +stream = client.responses.create( + model="assistant:asst_xxx", + input="流式介绍", + stream=True, +) +for event in stream: + if event.type == "response.output_text.delta": + print(event.delta, end="") +``` + +## 幂等重试 + +创建请求可以携带 `Idempotency-Key`: + +```bash +curl -X POST "https://api.example.com/v1/responses" \ + -H "Authorization: Bearer $REALTIME_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: order-A123-turn-1" \ + -d '{"model":"assistant:asst_xxx","input":"查询订单 A123"}' +``` + +- 同一 API Key、同一幂等键、相同请求会返回原 Response,并带 `X-Idempotent-Replayed: true`。 +- 相同幂等键对应不同请求时返回 `409 idempotency_key_conflict`。 +- 原请求仍在生成时返回 `409 idempotency_key_in_use`。 +- 幂等键最长 255 个字符;建议调用方使用业务请求 ID 或 UUID。 ## 动态变量 @@ -126,10 +213,50 @@ curl "https://api.example.com/v1/responses/resp_xxx" \ 如果父链存在多个尚未完成的 Client Tool,本轮必须一次提交全部对应结果。 +## 输入项与删除 + +输入项接口支持稳定游标分页,默认按倒序返回 20 项: + +```bash +curl "https://api.example.com/v1/responses/resp_xxx/input_items?order=asc&limit=20" \ + -H "Authorization: Bearer $REALTIME_API_KEY" +``` + +继续翻页时把上一页 `last_id` 传给 `after`。`limit` 范围为 1 到 100。 + +删除 Response: + +```bash +curl -X DELETE "https://api.example.com/v1/responses/resp_xxx" \ + -H "Authorization: Bearer $REALTIME_API_KEY" +``` + +删除使用内部 tombstone。该 ID 随后查询或作为新父响应时返回 404;已经存在的子 Response 仍可沿内部祖先链恢复上下文。正在生成的 Response 返回 409,不能通过删除来取消。 + +subset v1 不支持 background,因此 `POST /v1/responses/{id}/cancel` 明确返回 `400 unsupported_operation`。 + +## 请求关联与错误 + +所有 HTTP 响应都返回 `X-Request-ID`。可以在请求中传入自己的安全标识并由服务端透传,便于关联调用方日志;没有提供时服务端生成 `req_...`。 + +错误统一使用 OpenAI 风格 envelope: + +```json +{ + "error": { + "type": "invalid_request_error", + "code": "response_not_found", + "message": "Response not found", + "param": null + } +} +``` + ## 首版边界 - 仅支持 `runtimeMode=pipeline` 的 Prompt、Workflow、Dify 和 FastGPT 助手。 - 只支持文本 `message` 和 `function_call_output` 输入。 - `store` 必须为 `true` 或省略。 - 不支持 `conversation`、`background`、图片、文件以及调用方覆盖 instructions 或 tools。 +- `usage` 只在 Provider 提供可靠 token 统计时返回;否则为 `null`,不会估算。 - 不提供 `/v1/chat/completions` 兼容接口。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4860fa9..7bdbd8d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.7", + "openai": "7.10.0", "tailwindcss": "^4", "typescript": "^5" } @@ -9451,6 +9452,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.10.0.tgz", + "integrity": "sha512-sn9t2Kls7O52PwuF9BUTYNu4Gk/r0lXJyrgaNht4TNRlZFb3dJIGO0RciSgjARGCBRtWjySubAQFJttlzUvGQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index b8c95c0..d2fa40b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test:contracts": "node --test tests/*.test.mjs" }, "dependencies": { "@daily-co/daily-js": "^0.90.0", @@ -34,6 +35,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.7", + "openai": "7.10.0", "tailwindcss": "^4", "typescript": "^5" } diff --git a/frontend/tests/openai-responses-contract.test.mjs b/frontend/tests/openai-responses-contract.test.mjs new file mode 100644 index 0000000..b778f1b --- /dev/null +++ b/frontend/tests/openai-responses-contract.test.mjs @@ -0,0 +1,428 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import OpenAI from "openai"; + + +function jsonResponse(value, status = 200, extraHeaders = {}) { + return new Response(JSON.stringify(value), { + status, + headers: { + "Content-Type": "application/json", + "X-Request-ID": "req_js_contract", + ...extraHeaders, + }, + }); +} + + +function inputItems(input) { + if (typeof input === "string") { + return [ + { + id: "msg_input_contract", + type: "message", + role: "user", + content: [{ type: "input_text", text: input }], + status: "completed", + }, + ]; + } + return input.map((item, index) => ({ + id: item.id ?? `item_contract_${index}`, + status: item.status ?? "completed", + ...item, + })); +} + + +function completedResponse({ id, model, previousResponseId, input, output }) { + return { + id, + object: "response", + created_at: 1788739200, + completed_at: 1788739201, + status: "completed", + error: null, + incomplete_details: null, + instructions: null, + model, + output, + parallel_tool_calls: false, + previous_response_id: previousResponseId, + store: true, + tool_choice: "auto", + tools: [], + metadata: {}, + usage: null, + _input: inputItems(input), + }; +} + + +function publicResponse(response) { + const value = { ...response }; + delete value._input; + return value; +} + + +function sseResponse(response) { + const initial = { + ...publicResponse(response), + status: "in_progress", + completed_at: null, + output: [], + }; + const item = response.output[0]; + const events = []; + const append = (event) => { + events.push({ ...event, sequence_number: events.length }); + }; + append({ type: "response.created", response: initial }); + append({ type: "response.in_progress", response: initial }); + if (item.type === "message") { + const part = item.content[0]; + append({ + type: "response.output_item.added", + output_index: 0, + item: { ...item, status: "in_progress", content: [] }, + }); + append({ + type: "response.content_part.added", + item_id: item.id, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }); + append({ + type: "response.output_text.delta", + item_id: item.id, + output_index: 0, + content_index: 0, + delta: part.text, + logprobs: [], + }); + append({ + type: "response.output_text.done", + item_id: item.id, + output_index: 0, + content_index: 0, + text: part.text, + logprobs: [], + }); + append({ + type: "response.content_part.done", + item_id: item.id, + output_index: 0, + content_index: 0, + part, + }); + } else { + append({ + type: "response.output_item.added", + output_index: 0, + item: { ...item, status: "in_progress", arguments: "" }, + }); + append({ + type: "response.function_call_arguments.delta", + item_id: item.id, + output_index: 0, + delta: item.arguments, + }); + append({ + type: "response.function_call_arguments.done", + item_id: item.id, + output_index: 0, + name: item.name, + arguments: item.arguments, + }); + } + append({ type: "response.output_item.done", output_index: 0, item }); + append({ type: "response.completed", response: publicResponse(response) }); + const body = events + .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + .join(""); + return new Response(body, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "X-Request-ID": "req_js_contract", + }, + }); +} + + +function createContractFetch() { + const responses = new Map(); + const idempotency = new Map(); + let sequence = 0; + + const fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + assert.match(request.headers.get("authorization") ?? "", /^Bearer /); + const url = new URL(request.url); + + if (request.method === "GET" && url.pathname === "/v1/models") { + return jsonResponse({ + object: "list", + data: [ + { + id: "assistant:asst_js", + object: "model", + created: 1788739200, + owned_by: "interactive-media", + name: "JS contract assistant", + capabilities: ["responses", "realtime"], + assistant_id: "asst_js", + assistant_type: "prompt", + runtime_mode: "pipeline", + }, + ], + }); + } + + if (request.method === "POST" && url.pathname === "/v1/responses") { + const body = await request.clone().json(); + const idempotencyKey = request.headers.get("idempotency-key"); + const fingerprint = JSON.stringify(body); + if (idempotencyKey && idempotency.has(idempotencyKey)) { + const stored = idempotency.get(idempotencyKey); + if (stored.fingerprint !== fingerprint) { + return jsonResponse( + { + error: { + type: "invalid_request_error", + code: "idempotency_key_conflict", + message: "Idempotency-Key was used with a different request", + param: "Idempotency-Key", + }, + }, + 409, + ); + } + return jsonResponse(publicResponse(stored.response), 200, { + "X-Idempotent-Replayed": "true", + }); + } + + const parent = body.previous_response_id + ? responses.get(body.previous_response_id) + : undefined; + const model = body.model ?? parent?.model; + sequence += 1; + const responseId = `resp_js_${sequence}`; + const isToolCall = body.input === "调用客户端工具"; + const output = isToolCall + ? [ + { + id: "fc_js_contract", + type: "function_call", + status: "completed", + call_id: "call_js_contract", + name: "show_message", + arguments: '{"message":"请确认"}', + }, + ] + : [ + { + id: `msg_js_${sequence}`, + type: "message", + status: "completed", + role: "assistant", + content: [ + { + type: "output_text", + text: `第 ${sequence} 轮回复`, + annotations: [], + }, + ], + }, + ]; + const response = completedResponse({ + id: responseId, + model, + previousResponseId: body.previous_response_id ?? null, + input: body.input, + output, + }); + responses.set(responseId, response); + if (idempotencyKey) { + idempotency.set(idempotencyKey, { fingerprint, response }); + } + return body.stream ? sseResponse(response) : jsonResponse(publicResponse(response)); + } + + const inputItemsMatch = url.pathname.match( + /^\/v1\/responses\/([^/]+)\/input_items$/, + ); + if (request.method === "GET" && inputItemsMatch) { + const response = responses.get(inputItemsMatch[1]); + if (!response) return jsonResponse({ error: { message: "Not found" } }, 404); + const data = response._input; + return jsonResponse({ + object: "list", + data, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + has_more: false, + }); + } + + const responseMatch = url.pathname.match(/^\/v1\/responses\/([^/]+)$/); + if (responseMatch && request.method === "GET") { + const response = responses.get(responseMatch[1]); + return response + ? jsonResponse(publicResponse(response)) + : jsonResponse( + { + error: { + type: "invalid_request_error", + code: "response_not_found", + message: "Response not found", + param: null, + }, + }, + 404, + ); + } + if (responseMatch && request.method === "DELETE") { + const deleted = responses.delete(responseMatch[1]); + return deleted + ? jsonResponse({ id: responseMatch[1], object: "response", deleted: true }) + : jsonResponse({ error: { message: "Not found" } }, 404); + } + return jsonResponse({ error: { message: "Unexpected contract request" } }, 500); + }; + + return { fetch, responses }; +} + + +test("OpenAPI snapshot exposes the public Responses surface", () => { + const openapiPath = new URL("../../backend/contracts/openapi.json", import.meta.url); + const openapi = JSON.parse(readFileSync(openapiPath, "utf8")); + assert.ok(openapi.paths["/v1/responses"].post); + assert.ok(openapi.paths["/v1/responses/{response_id}/input_items"].get); + assert.ok(openapi.paths["/v1/responses/{response_id}"].delete); + assert.ok(openapi.paths["/v1/models"].get); +}); + + +test("official JavaScript SDK completes three stateful turns and discovery", async () => { + const contract = createContractFetch(); + const client = new OpenAI({ + apiKey: "sk-rt-js-contract", + baseURL: "https://contract.test/v1", + fetch: contract.fetch, + maxRetries: 0, + }); + + const models = await client.models.list(); + assert.equal(models.data[0].id, "assistant:asst_js"); + + const first = await client.responses.create({ + model: "assistant:asst_js", + input: "第一轮", + }); + const second = await client.responses.create({ + previous_response_id: first.id, + input: "第二轮", + }); + const third = await client.responses.create({ + previous_response_id: second.id, + input: "第三轮", + }); + assert.equal(third.previous_response_id, second.id); + assert.equal(third.model, "assistant:asst_js"); + assert.equal(third.output_text, "第 3 轮回复"); + + const page = await client.responses.inputItems.list(third.id, { order: "asc" }); + assert.equal(page.data[0].type, "message"); + const retrieved = await client.responses.retrieve(third.id); + assert.equal(retrieved.id, third.id); + + const deleted = await client.responses.delete(first.id); + assert.equal(deleted.deleted, true); +}); + + +test("official JavaScript SDK parses SSE and Client Tool continuation", async () => { + const contract = createContractFetch(); + const client = new OpenAI({ + apiKey: "sk-rt-js-contract", + baseURL: "https://contract.test/v1", + fetch: contract.fetch, + maxRetries: 0, + }); + + const stream = await client.responses.create({ + model: "assistant:asst_js", + input: "流式输出", + stream: true, + }); + const events = []; + for await (const event of stream) events.push(event); + assert.deepEqual( + events.map((event) => event.sequence_number), + Array.from({ length: events.length }, (_, index) => index), + ); + assert.equal(events.at(-1).type, "response.completed"); + const streamed = await client.responses.retrieve( + events.at(-1).response.id, + ); + assert.equal( + streamed.output_text, + events.at(-1).response.output[0].content[0].text, + ); + + const toolStream = await client.responses.create({ + model: "assistant:asst_js", + input: "调用客户端工具", + stream: true, + }); + const toolEvents = []; + for await (const event of toolStream) toolEvents.push(event); + assert.ok( + toolEvents.some( + (event) => event.type === "response.function_call_arguments.done", + ), + ); + const tool = toolEvents.at(-1).response; + const continued = await client.responses.create({ + previous_response_id: tool.id, + input: [ + { + type: "function_call_output", + call_id: tool.output[0].call_id, + output: '{"status":"ok"}', + }, + ], + }); + assert.equal(continued.status, "completed"); +}); + + +test("official JavaScript SDK preserves idempotency semantics", async () => { + const contract = createContractFetch(); + const client = new OpenAI({ + apiKey: "sk-rt-js-contract", + baseURL: "https://contract.test/v1", + fetch: contract.fetch, + maxRetries: 0, + }); + const options = { headers: { "Idempotency-Key": "js-contract-key" } }; + const request = { model: "assistant:asst_js", input: "幂等请求" }; + + const first = await client.responses.create(request, options); + const replay = await client.responses.create(request, options); + assert.equal(replay.id, first.id); + await assert.rejects( + client.responses.create( + { model: "assistant:asst_js", input: "不同请求" }, + options, + ), + (error) => error.status === 409, + ); +});