feat: add stateful responses API

This commit is contained in:
Xin Wang
2026-09-07 00:15:32 +08:00
parent ab409ba9c0
commit 43f2cc445e
22 changed files with 2461 additions and 5 deletions
+1
View File
@@ -18,6 +18,7 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# 用独立随机值签名长期 API Key 摘要和短期 Client Secret;生产环境必须修改。
REALTIME_TOKEN_SECRET=replace-with-a-long-random-secret
REALTIME_CLIENT_SECRET_TTL_SECONDS=60
RESPONSES_REQUEST_TIMEOUT_SECONDS=120
# ---- RustFS / S3-compatible storage ----
# Use 127.0.0.1 (not localhost) if HTTP_PROXY is set — proxies often return 502 for localhost.
+4
View File
@@ -25,6 +25,7 @@ import settings
import uvicorn
from db.session import sync_default_tools, sync_interface_definitions
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
from services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker
from services.test_runs.orchestrator import (
@@ -45,6 +46,7 @@ from routes import (
model_registry,
node_types,
openai_realtime,
openai_responses,
realtime_api_keys,
site_settings,
test_cases,
@@ -64,6 +66,7 @@ async def lifespan(_app: FastAPI):
await recover_interrupted_analyses()
await recover_interrupted_webhooks()
await recover_interrupted_test_runs()
await recover_interrupted_responses()
analysis_worker = asyncio.create_task(
run_analysis_worker(), name="post-call-analysis-worker"
)
@@ -102,6 +105,7 @@ app.include_router(mcp_servers.router)
app.include_router(model_registry.router)
app.include_router(node_types.router)
app.include_router(openai_realtime.router)
app.include_router(openai_responses.router)
app.include_router(realtime_api_keys.router)
app.include_router(site_settings.router)
app.include_router(test_cases.router)
+46
View File
@@ -424,6 +424,52 @@ class RealtimeApiKey(Base):
)
class OpenAIResponse(Base):
"""Persisted state for one public Responses API generation."""
__tablename__ = "openai_responses"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
api_key_id: Mapped[str] = mapped_column(
String(40),
ForeignKey("realtime_api_keys.id", ondelete="CASCADE"),
index=True,
)
assistant_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("assistants.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
conversation_id: Mapped[str] = mapped_column(
String(40),
ForeignKey("conversation_sessions.id", ondelete="CASCADE"),
index=True,
)
previous_response_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("openai_responses.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(String(16), index=True, default="in_progress")
input_items: Mapped[list] = mapped_column(JSONB, default=list)
output_items: Mapped[list] = mapped_column(JSONB, default=list)
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)
dynamic_variables: Mapped[dict] = mapped_column(JSONB, default=dict)
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
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class WebhookDelivery(Base):
"""Immutable event snapshot plus its asynchronous delivery state."""
@@ -0,0 +1,106 @@
"""add stateful OpenAI Responses records
Revision ID: 20260826_0024
Revises: 20260818_0023
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260826_0024"
down_revision: str | Sequence[str] | None = "20260818_0023"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"openai_responses",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("api_key_id", sa.String(length=40), nullable=False),
sa.Column("assistant_id", sa.String(length=40), nullable=True),
sa.Column("conversation_id", sa.String(length=40), nullable=False),
sa.Column("previous_response_id", sa.String(length=40), nullable=True),
sa.Column(
"status", sa.String(length=16), server_default="in_progress", nullable=False
),
sa.Column(
"input_items",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"),
nullable=False,
),
sa.Column(
"output_items",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"),
nullable=False,
),
sa.Column(
"context_messages",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"),
nullable=False,
),
sa.Column(
"runtime_state",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"error", postgresql.JSONB(astext_type=sa.Text()), nullable=True
),
sa.Column(
"dynamic_variables",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["api_key_id"], ["realtime_api_keys.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["assistant_id"], ["assistants.id"], ondelete="SET NULL"
),
sa.ForeignKeyConstraint(
["conversation_id"], ["conversation_sessions.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["previous_response_id"], ["openai_responses.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
for column in (
"api_key_id",
"assistant_id",
"conversation_id",
"previous_response_id",
"status",
):
op.create_index(
f"ix_openai_responses_{column}", "openai_responses", [column]
)
def downgrade() -> None:
op.drop_table("openai_responses")
+358
View File
@@ -0,0 +1,358 @@
"""Public stateful OpenAI Responses-compatible text endpoints."""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from typing import Any
import settings
from db.session import SessionLocal
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from loguru import logger
from services.openai_realtime.auth import (
RealtimeAuthError,
RealtimeCredential,
authenticate_bearer,
bearer_from_authorization,
)
from services.openai_responses.protocol import (
ResponseRequest,
ResponsesAPIError,
error_payload,
new_id,
parse_request,
response_payload,
)
from services.openai_responses.runner import ResponseRunError, ResponsesTextRunner
from services.openai_responses.state import (
PreparedResponse,
finish_response,
get_response,
prepare_response,
)
router = APIRouter(prefix="/v1/responses", tags=["openai-responses"])
async def _credential(request: Request) -> RealtimeCredential:
token = bearer_from_authorization(request.headers.get("authorization"))
async with SessionLocal() as db:
credential = await authenticate_bearer(db, token)
if credential.ephemeral:
raise RealtimeAuthError("A long-lived API key is required")
return credential
def _json_error(error: ResponsesAPIError) -> JSONResponse:
return 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
async def _run_prepared(
prepared: PreparedResponse,
*,
on_delta=None,
text_item_id: str,
):
try:
async with asyncio.timeout(settings.RESPONSES_REQUEST_TIMEOUT_SECONDS):
result = await ResponsesTextRunner().run(
prepared.config,
history_messages=prepared.history_messages,
current_messages=prepared.current_messages,
runtime_state=prepared.runtime_state,
client_tool_results=prepared.client_tool_results,
on_delta=on_delta,
)
return await finish_response(
prepared.row.id,
status="completed",
output_items=_output_items(
text=result.text,
text_item_id=text_item_id,
function_calls=result.function_calls,
),
context_messages=result.context_messages,
dynamic_variables=result.dynamic_variables,
runtime_state=result.runtime_state,
)
except TimeoutError as exc:
await finish_response(
prepared.row.id,
status="failed",
output_items=[],
error={"code": "response_timeout", "message": "Response timed out"},
)
raise ResponsesAPIError(
"Response timed out", code="response_timeout", status_code=504
) from exc
except asyncio.CancelledError:
await finish_response(
prepared.row.id,
status="cancelled",
output_items=[],
error={"code": "client_disconnected", "message": "Client disconnected"},
)
raise
except ResponseRunError as exc:
await finish_response(
prepared.row.id,
status="failed",
output_items=[],
error={"code": "response_failed", "message": str(exc)},
)
raise ResponsesAPIError(
"Response generation failed",
code="response_failed",
status_code=500,
) from exc
except Exception as exc: # noqa: BLE001
logger.exception(f"Responses generation failed: {exc}")
await finish_response(
prepared.row.id,
status="failed",
output_items=[],
error={"code": "internal_error", "message": "Response generation failed"},
)
raise ResponsesAPIError(
"Response generation failed", code="internal_error", status_code=500
) from exc
async def _stream_response(
prepared: PreparedResponse,
*,
text_item_id: str,
) -> AsyncIterator[bytes]:
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
text_started = False
async def on_delta(delta: str) -> None:
await queue.put({"kind": "delta", "delta": delta})
async def generate() -> None:
try:
row = await _run_prepared(
prepared,
on_delta=on_delta,
text_item_id=text_item_id,
)
await queue.put({"kind": "completed", "row": row})
except ResponsesAPIError as exc:
await queue.put({"kind": "failed", "error": exc})
finally:
await queue.put(None)
task = asyncio.create_task(generate(), name=f"stream-{prepared.row.id}")
try:
created = response_payload(prepared.row)
yield _sse({"type": "response.created", "response": created})
yield _sse({"type": "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, "")
pending_item["status"] = "in_progress"
yield _sse(
{
"type": "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 _sse(
{
"type": "response.output_text.delta",
"item_id": text_item_id,
"output_index": 0,
"content_index": 0,
"delta": event["delta"],
}
)
continue
if event["kind"] == "failed":
error: ResponsesAPIError = event["error"]
yield _sse({"type": "error", **error_payload(error)})
failed = await get_response(
prepared.row.id, api_key_id=prepared.row.api_key_id
)
yield _sse(
{"type": "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})
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@router.post("")
async def create_response(request: Request):
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)
text_item_id = new_id("msg")
if parsed.stream:
return StreamingResponse(
_stream_response(prepared, text_item_id=text_item_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
row = await _run_prepared(prepared, text_item_id=text_item_id)
return response_payload(row)
except RealtimeAuthError as exc:
return _json_error(
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
)
except (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}")
async def retrieve_response(response_id: str, request: Request):
try:
credential = await _credential(request)
row = await get_response(response_id, api_key_id=credential.api_key_id)
return response_payload(row)
except RealtimeAuthError as exc:
return _json_error(
ResponsesAPIError(str(exc), code="invalid_api_key", status_code=401)
)
except ResponsesAPIError as exc:
return _json_error(exc)
+20 -1
View File
@@ -24,6 +24,8 @@ class DifyBrain(BaseBrain):
def __init__(self):
self._user_id = f"ai-video-{uuid4().hex}"
self._client: AsyncClient | None = None
self._llm: DifyLLMService | None = None
self._restored_conversation_id = ""
def _get_client(self, cfg: AssistantConfig) -> AsyncClient:
if self._client is None:
@@ -54,8 +56,25 @@ class DifyBrain(BaseBrain):
return cfg.greeting
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
return DifyLLMService(
self._llm = DifyLLMService(
cfg,
client=self._get_client(cfg),
user_id=self._user_id,
conversation_id=self._restored_conversation_id,
)
return self._llm
def restore_text_state(self, snapshot: dict) -> None:
if snapshot.get("type") != "dify":
raise ValueError("Responses runtime state does not belong to Dify")
self._user_id = str(snapshot.get("user_id") or self._user_id)
self._restored_conversation_id = str(snapshot.get("conversation_id") or "")
def text_state_snapshot(self) -> dict:
return {
"type": "dify",
"user_id": self._user_id,
"conversation_id": (
self._llm.conversation_id if self._llm is not None else ""
),
}
+6 -1
View File
@@ -54,6 +54,7 @@ class DifyLLMService(LLMService):
*,
client: AsyncClient | None = None,
user_id: str | None = None,
conversation_id: str = "",
):
super().__init__(
settings=LLMSettings(
@@ -75,7 +76,11 @@ class DifyLLMService(LLMService):
api_base=normalize_api_base(cfg.dify_api_url),
)
self._user_id = user_id or f"ai-video-{uuid4().hex}"
self._conversation_id = ""
self._conversation_id = conversation_id
@property
def conversation_id(self) -> str:
return self._conversation_id
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
+8
View File
@@ -66,3 +66,11 @@ class FastGPTBrain(BaseBrain):
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
return FastGPTLLMService(cfg, chat_id=self._chat_id)
def restore_text_state(self, snapshot: dict) -> None:
if snapshot.get("type") != "fastgpt":
raise ValueError("Responses runtime state does not belong to FastGPT")
self._chat_id = str(snapshot.get("chat_id") or self._chat_id)
def text_state_snapshot(self) -> dict:
return {"type": "fastgpt", "chat_id": self._chat_id}
+22
View File
@@ -83,6 +83,7 @@ class PromptBrain(BaseBrain):
self._startup_failed = False
self._greeting_pending = False
self._entry_dispatched = False
self._restored_text_state: dict[str, Any] | None = None
async def greeting(self, cfg: AssistantConfig) -> str:
# The built-in opening Message owns the greeting so speech and the
@@ -127,6 +128,12 @@ class PromptBrain(BaseBrain):
self._startup_failed = False
self._greeting_pending = False
self._entry_dispatched = False
if self._restored_text_state is not None:
state = self._restored_text_state
self._preflight_finished = bool(state.get("preflight_finished"))
self._opening_finished = bool(state.get("opening_finished"))
self._opening_started = self._opening_finished
self._entry_dispatched = bool(state.get("entry_dispatched"))
schemas: list[FunctionSchema] = []
registered_names: set[str] = set()
binding_by_id = {binding.tool_id: binding for binding in cfg.tool_bindings}
@@ -171,6 +178,21 @@ class PromptBrain(BaseBrain):
)
runtime.set_tools(schemas)
def restore_text_state(self, snapshot: dict[str, Any]) -> None:
"""Restore Prompt startup progress for a later Responses API turn."""
if snapshot.get("type") != "prompt":
raise ValueError("Responses runtime state does not belong to a prompt")
self._restored_text_state = dict(snapshot)
def text_state_snapshot(self) -> dict[str, Any]:
"""Prevent completed startup actions from running again next turn."""
return {
"type": "prompt",
"preflight_finished": self._preflight_finished,
"opening_finished": self._opening_finished,
"entry_dispatched": self._entry_dispatched,
}
async def setup_realtime(
self,
cfg: AssistantConfig,
+40
View File
@@ -187,6 +187,7 @@ class WorkflowBrain(BaseBrain):
self._next_message_token = 1
self._pending_message: _MessageContinuation | None = None
self._realtime_controller: WorkflowRealtimeController | None = None
self._restored_text_node_id: str | None = None
async def greeting(self, _cfg: AssistantConfig) -> str:
"""Workflow opening speech belongs to an explicit Message or Agent."""
@@ -274,6 +275,18 @@ class WorkflowBrain(BaseBrain):
if self._realtime_controller is not None:
await self._realtime_controller.start()
return
if self._restored_text_node_id:
node_id = self._restored_text_node_id
self._restored_text_node_id = None
self._state.enter(node_id, WorkflowStatus.WAITING_USER)
await self._emit_node_active(node_id)
await self._emit_variables(reason="restored", node_id=node_id)
if self._manager is None:
raise RuntimeError("Workflow FlowManager 尚未初始化")
node_config = await self._resolve_path(node_id)
await self._activate_node_config(node_config, initialize=True)
logger.info(f"工作流文本状态已恢复: 当前节点={self._manager.current_node}")
return
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
await self._emit_node_active(self._engine.start_id)
await self._emit_variables(
@@ -287,6 +300,33 @@ class WorkflowBrain(BaseBrain):
await self._activate_node_config(node_config, initialize=True)
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
def restore_text_state(self, snapshot: dict[str, Any]) -> None:
"""Restore one stable Responses API workflow node before connecting."""
if not snapshot:
return
if snapshot.get("type") != "workflow":
raise ValueError("Responses runtime state does not belong to a workflow")
if snapshot.get("revision") != self._engine.revision:
raise ValueError("Workflow definition changed after the previous response")
node_id = str(snapshot.get("current_node_id") or "")
if not self._engine.node_type(node_id):
raise ValueError("Previous workflow node no longer exists")
self._restored_text_node_id = node_id
def text_state_snapshot(self) -> dict[str, Any]:
"""Return the minimum durable state needed by the next text request."""
node_id = (
str(self._manager.current_node)
if self._manager is not None and self._manager.current_node
else self._state.current_node_id
)
return {
"type": "workflow",
"revision": self._engine.revision,
"current_node_id": node_id,
"status": self._state.status.value,
}
async def _initial_node_config(self) -> NodeConfig:
"""Only a default-only Start advances before the first user turn."""
outgoing = self._engine.outgoing(self._engine.start_id)
@@ -0,0 +1 @@
"""Stateful OpenAI Responses-compatible text API."""
@@ -0,0 +1,241 @@
"""Validation and wire helpers for the supported Responses API subset."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
from db.models import OpenAIResponse
LOCKED_FIELDS = {"instructions", "tools", "tool_choice"}
ALLOWED_FIELDS = {
"input",
"model",
"previous_response_id",
"store",
"stream",
"x_interactive_media",
}
UNSUPPORTED_FIELDS = {
"background",
"conversation",
"include",
"max_output_tokens",
"parallel_tool_calls",
"reasoning",
"temperature",
"text",
"top_p",
"truncation",
}
@dataclass
class ResponsesAPIError(ValueError):
message: str
code: str = "invalid_request_error"
param: str | None = None
status_code: int = 400
def __str__(self) -> str:
return self.message
@dataclass(frozen=True)
class ResponseRequest:
model: str | None
previous_response_id: str | None
input_items: list[dict[str, Any]]
stream: bool
dynamic_variables: dict[str, Any]
def error_payload(error: ResponsesAPIError) -> dict[str, Any]:
return {
"error": {
"type": "invalid_request_error",
"code": error.code,
"message": error.message,
"param": error.param,
}
}
def _text_content(value: object, *, role: str) -> list[dict[str, str]]:
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}]
if not isinstance(value, list) or not value:
raise ResponsesAPIError("Message content must be text", param="input")
result: list[dict[str, str]] = []
for part in value:
if not isinstance(part, dict) or part.get("type") != expected:
raise ResponsesAPIError(
f"Only {expected} content is supported", param="input"
)
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})
return result
def normalize_input(value: object) -> list[dict[str, Any]]:
if isinstance(value, str):
return [
{
"type": "message",
"role": "user",
"content": _text_content(value, role="user"),
}
]
if not isinstance(value, list) or not value:
raise ResponsesAPIError("input must be a non-empty string or array", param="input")
items: list[dict[str, Any]] = []
for index, raw in enumerate(value):
param = f"input[{index}]"
if not isinstance(raw, dict):
raise ResponsesAPIError("Input item must be an object", param=param)
item_type = str(raw.get("type") or "message")
if item_type == "message":
role = str(raw.get("role") or "user")
if role not in {"user", "assistant"}:
raise ResponsesAPIError(
"Message role must be user or assistant", param=f"{param}.role"
)
items.append(
{
"type": "message",
"role": role,
"content": _text_content(raw.get("content"), role=role),
}
)
continue
if item_type == "function_call_output":
call_id = str(raw.get("call_id") or "").strip()
if not call_id:
raise ResponsesAPIError(
"function_call_output requires call_id",
param=f"{param}.call_id",
)
output = raw.get("output")
if not isinstance(output, str):
output = json.dumps(output, ensure_ascii=False, default=str)
items.append(
{
"type": "function_call_output",
"call_id": call_id,
"output": output,
}
)
continue
raise ResponsesAPIError(
f"Unsupported input item type: {item_type}",
code="unsupported_parameter",
param=f"{param}.type",
)
return items
def parse_request(body: object) -> ResponseRequest:
if not isinstance(body, dict):
raise ResponsesAPIError("Request body must be an object")
unknown = sorted(set(body) - ALLOWED_FIELDS - LOCKED_FIELDS - UNSUPPORTED_FIELDS)
if unknown:
raise ResponsesAPIError(
f"Unsupported parameter: {unknown[0]}",
code="unsupported_parameter",
param=unknown[0],
)
locked = sorted(LOCKED_FIELDS.intersection(body))
if locked:
raise ResponsesAPIError(
f"{locked[0]} is owned by the selected assistant",
code="immutable_configuration",
param=locked[0],
)
unsupported = sorted(UNSUPPORTED_FIELDS.intersection(body))
if unsupported:
raise ResponsesAPIError(
f"Unsupported parameter: {unsupported[0]}",
code="unsupported_parameter",
param=unsupported[0],
)
if "store" in body and body["store"] is not True:
raise ResponsesAPIError(
"store must be true for this stateful API",
code="unsupported_parameter",
param="store",
)
stream = body.get("stream", False)
if not isinstance(stream, bool):
raise ResponsesAPIError("stream must be a boolean", param="stream")
extension = body.get("x_interactive_media") or {}
if not isinstance(extension, dict):
raise ResponsesAPIError(
"x_interactive_media must be an object", param="x_interactive_media"
)
variables = extension.get("variables") or {}
if not isinstance(variables, dict):
raise ResponsesAPIError(
"variables must be an object",
param="x_interactive_media.variables",
)
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")
raw_previous = body.get("previous_response_id")
if raw_previous is not None and not isinstance(raw_previous, str):
raise ResponsesAPIError(
"previous_response_id must be a string", param="previous_response_id"
)
model = (raw_model or "").strip() or None
previous = (raw_previous or "").strip() or None
if not model and not previous:
raise ResponsesAPIError(
"model is required for the first response", param="model"
)
if "input" not in body:
raise ResponsesAPIError("input is required", param="input")
return ResponseRequest(
model=model,
previous_response_id=previous,
input_items=normalize_input(body["input"]),
stream=stream,
dynamic_variables=dict(variables),
)
def new_id(prefix: str) -> str:
return f"{prefix}_{uuid4().hex}"
def response_payload(row: OpenAIResponse) -> dict[str, Any]:
return {
"id": row.id,
"object": "response",
"created_at": int(row.created_at.timestamp()),
"completed_at": (
int(row.completed_at.timestamp()) if row.completed_at else None
),
"status": row.status,
"error": row.error,
"incomplete_details": None,
"instructions": None,
"model": f"assistant:{row.assistant_id}" if row.assistant_id else None,
"output": list(row.output_items or []),
"parallel_tool_calls": False,
"previous_response_id": row.previous_response_id,
"store": True,
"tool_choice": "auto",
"tools": [],
"metadata": {},
"usage": None,
}
+607
View File
@@ -0,0 +1,607 @@
"""Transportless production runner for one Responses API turn."""
from __future__ import annotations
import asyncio
from copy import deepcopy
import json
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from db.session import SessionLocal
from loguru import logger
from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame,
DataFrame,
EndFrame,
ErrorFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMMessagesAppendFrame,
LLMTextFrame,
ManuallySwitchServiceFrame,
TTSSpeakFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.llm_service import FunctionCallParams
from pipecat.workers.runner import WorkerRunner
from services.brains import BrainRuntime, build_brain
from services.client_tools import ClientToolResponseWaitMode
from services.knowledge import search as search_knowledge
from services.openai_responses.protocol import new_id
from services.pipecat.call_lifecycle import CallEndCoordinator
from services.pipecat.processors import (
KnowledgeRetrievalProcessor,
UserTurnRoutingProcessor,
)
from services.pipecat.service_factory import config_with_resource
from services.pipecat.workflow_services import build_workflow_llm_switcher
IDLE_SETTLE_SECONDS = 0.2
WORKER_START_TIMEOUT_SECONDS = 5.0
class ResponseRunError(RuntimeError):
pass
DeltaCallback = Callable[[str], Awaitable[None]]
@dataclass(frozen=True)
class ResponseRunResult:
text: str
function_calls: list[dict[str, Any]]
context_messages: list[dict[str, Any]]
dynamic_variables: dict[str, Any]
runtime_state: dict[str, Any]
@dataclass
class _ResponseWindow:
active_llm: int = 0
active_assistant: int = 0
pending_tools: set[str] = field(default_factory=set)
activity_count: int = 0
last_activity_at: float = field(default_factory=time.monotonic)
def touch(self) -> None:
self.activity_count += 1
self.last_activity_at = time.monotonic()
@property
def idle(self) -> bool:
return self.active_llm == 0 and self.active_assistant == 0 and not self.pending_tools
@dataclass
class _BoundaryFrame(DataFrame):
reached: asyncio.Event
class _CaptureProcessor(FrameProcessor):
def __init__(self, on_delta: DeltaCallback | None) -> None:
super().__init__()
self.on_delta = on_delta
self.window = _ResponseWindow()
self.deltas: list[str] = []
self.outputs: list[str] = []
self.error: str | None = None
def reset(self) -> None:
self.deltas.clear()
self.outputs.clear()
self.error = None
self.window.activity_count = 0
self.window.last_activity_at = time.monotonic()
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, LLMFullResponseStartFrame):
self.window.active_llm += 1
self.window.touch()
elif isinstance(frame, LLMTextFrame):
if frame.text:
self.deltas.append(frame.text)
self.window.touch()
if self.on_delta is not None:
await self.on_delta(frame.text)
elif isinstance(frame, LLMFullResponseEndFrame):
self.window.active_llm = max(0, self.window.active_llm - 1)
self.window.touch()
await self.push_frame(frame, direction)
await self.push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM)
return
elif isinstance(frame, FunctionCallInProgressFrame):
self.window.pending_tools.add(frame.tool_call_id)
self.window.touch()
elif isinstance(frame, FunctionCallResultFrame):
self.window.pending_tools.discard(frame.tool_call_id)
self.window.touch()
elif isinstance(frame, TTSSpeakFrame):
text = frame.text.strip()
if text:
self.outputs.append(text)
self.window.touch()
return
elif isinstance(frame, ErrorFrame):
self.error = frame.error or "Pipeline execution failed"
self.window.pending_tools.clear()
self.window.touch()
elif isinstance(frame, _BoundaryFrame):
frame.reached.set()
self.window.touch()
elif isinstance(frame, (EndFrame, CancelFrame)):
self.window.touch()
await self.push_frame(frame, direction)
class ResponsesClientToolPort:
"""Turn a configured client-side tool invocation into a Response item."""
def __init__(self, results: list[dict[str, Any]] | None = None) -> None:
self.calls: list[dict[str, Any]] = []
self.called = asyncio.Event()
self._results = list(results or [])
def supports(self, _function_name: str) -> bool:
return True
async def call(
self,
function_name: str,
arguments: dict[str, Any],
*,
timeout_seconds: float,
wait_for_response: bool = True,
response_wait_mode: ClientToolResponseWaitMode = "timeout",
interrupt_on_result: bool = False,
) -> 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:
self._results.pop(index)
return dict(pending.get("result") or {})
item = {
"id": new_id("fc"),
"type": "function_call",
"status": "completed",
"call_id": new_id("call"),
"name": function_name,
"arguments": json.dumps(arguments, ensure_ascii=False),
}
self.calls.append(item)
self.called.set()
# The HTTP response carries this call back to the caller. The worker is
# cancelled by ResponsesTextRunner after collecting parallel calls.
await asyncio.Future()
raise AssertionError("unreachable")
def _context_snapshot(
context: LLMContext,
*,
function_calls: list[dict[str, Any]],
fallback_messages: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Keep model-visible state and align provider call IDs with public IDs."""
messages = [
deepcopy(message)
for message in context.get_messages()
if message.get("role") != "system"
]
if fallback_messages:
messages.extend(deepcopy(fallback_messages))
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
if not matched:
messages.append(
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": public_call["call_id"],
"type": "function",
"function": {
"name": public_call["name"],
"arguments": public_call["arguments"],
},
}
],
}
)
return json.loads(json.dumps(messages, ensure_ascii=False, default=str))
def _public_variables(brain, cfg: AssistantConfig) -> dict[str, Any]:
store = getattr(brain, "_store", None)
public_values = getattr(store, "public_values", None)
if callable(public_values):
return dict(public_values())
return {
name: value
for name, value in (cfg.dynamic_variables or {}).items()
if not name.startswith(("system__", "secret__"))
}
def _runtime_state(brain) -> dict[str, Any]:
snapshot = getattr(brain, "text_state_snapshot", None)
return dict(snapshot()) if callable(snapshot) else {}
async def _wait_until_idle(
capture: _CaptureProcessor,
runner_task: asyncio.Task[None],
*,
require_activity: bool,
client_tools: ResponsesClientToolPort | None = None,
) -> str:
while True:
if capture.error:
raise ResponseRunError(capture.error)
if client_tools is not None and client_tools.called.is_set():
# Let calls emitted in the same provider batch enter the port.
await asyncio.sleep(0.05)
return "function_call"
if runner_task.done():
await runner_task
if capture.outputs or capture.deltas:
return "text"
raise ResponseRunError("Pipeline ended before producing a response")
active = capture.window.activity_count > 0
settled = time.monotonic() - capture.window.last_activity_at >= IDLE_SETTLE_SECONDS
if capture.window.idle and settled and (active or not require_activity):
return "text"
await asyncio.sleep(0.05)
class ResponsesTextRunner:
async def run(
self,
cfg: AssistantConfig,
*,
history_messages: list[dict[str, Any]],
current_messages: list[dict[str, Any]],
runtime_state: dict[str, Any] | None = None,
client_tool_results: list[dict[str, Any]] | None = None,
on_delta: DeltaCallback | None = None,
) -> ResponseRunResult:
if cfg.runtimeMode != "pipeline":
raise ResponseRunError("Responses API only supports pipeline assistants")
if not current_messages:
raise ResponseRunError("The current response has no usable input")
brain = build_brain(cfg)
restore_state = getattr(brain, "restore_text_state", None)
if runtime_state and callable(restore_state):
restore_state(runtime_state)
knowledge_config = cfg.knowledge_retrieval_config or {}
knowledge_mode = str(knowledge_config.get("mode") or "automatic")
def with_knowledge_hint(text: str) -> str:
if cfg.type != "prompt" or not cfg.knowledge_base_id:
return text
mode_text = (
"系统会在每轮用户问题前自动提供相关资料。"
if knowledge_mode == "automatic"
else "需要业务资料时先调用 search_knowledge_base。"
)
return f"{text}\n\n你已连接内部知识库。{mode_text}"
system_prompt = with_knowledge_hint(brain.system_prompt(cfg))
context = LLMContext(
messages=(
list(history_messages)
if cfg.type == "workflow"
else [
{"role": "system", "content": system_prompt},
*history_messages,
]
)
)
graph_settings = cfg.graph.get("settings") or {}
default_resource = cfg.workflow_model_resources.get(
str(graph_settings.get("defaultLlmResourceId") or "")
)
llm_cfg = (
config_with_resource(cfg, default_resource)
if cfg.type == "workflow" and default_resource is not None
else cfg
)
llm = brain.build_llm(llm_cfg, context)
llm_services: dict[str, FrameProcessor] = {}
current_llm_service: FrameProcessor = llm
if cfg.type == "workflow":
llm, llm_services, current_llm_service = build_workflow_llm_switcher(
cfg, llm
)
aggregators = LLMContextAggregatorPair(context)
user_aggregator = aggregators.user()
assistant_aggregator = aggregators.assistant()
capture = _CaptureProcessor(on_delta)
knowledge = KnowledgeRetrievalProcessor(
cfg.knowledge_base_id if knowledge_mode == "automatic" else None,
top_n=int(knowledge_config.get("top_n", knowledge_config.get("topN", 5))),
score_threshold=float(
knowledge_config.get(
"score_threshold", knowledge_config.get("scoreThreshold", 0.0)
)
),
)
pipeline = Pipeline(
[
user_aggregator,
UserTurnRoutingProcessor(brain),
knowledge,
llm,
capture,
assistant_aggregator,
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(enable_metrics=False),
enable_rtvi=False,
enable_turn_tracking=False,
idle_timeout_secs=None,
)
runner = WorkerRunner(handle_sigint=False, check_dangling_tasks=False)
worker_started = asyncio.Event()
@worker.event_handler("on_pipeline_started")
async def on_pipeline_started(_worker, _frame):
worker_started.set()
@worker.event_handler("on_pipeline_error")
async def on_pipeline_error(_worker, frame):
capture.error = frame.error or "Pipeline execution failed"
capture.window.touch()
client_tools = ResponsesClientToolPort(client_tool_results)
workflow_vision_scope: dict[str, Any] = {"enabled": False}
async def queue_call_end(reason: str) -> None:
await worker.queue_frame(EndFrame(reason=reason))
call_end = CallEndCoordinator(queue_call_end)
current_service = current_llm_service
async def switch_services(
llm_resource_id: str | None,
_asr_resource_id: str | None,
_tts_resource_id: str | None,
) -> None:
nonlocal current_service
target = (
llm_services.get(llm_resource_id)
if llm_resource_id
else current_llm_service
)
if target is None:
raise ValueError(f"Workflow LLM resource not found: {llm_resource_id}")
if target is current_service:
return
await worker.queue_frame(ManuallySwitchServiceFrame(service=target))
current_service = target
def set_system_prompt(text: str) -> None:
messages = context.get_messages()
text = with_knowledge_hint(text)
if messages and messages[0].get("role") == "system":
messages[0] = {"role": "system", "content": text}
else:
messages.insert(0, {"role": "system", "content": text})
knowledge_schema: FunctionSchema | None = None
if cfg.type == "prompt" and cfg.knowledge_base_id and knowledge_mode == "on_demand":
knowledge_schema = FunctionSchema(
name="search_knowledge_base",
description="在当前助手绑定的知识库中检索资料。",
properties={
"query": {"type": "string", "description": "完整问题或检索关键词"}
},
required=["query"],
)
async def search_bound_knowledge(params: FunctionCallParams) -> None:
query = str(params.arguments.get("query") or "").strip()
if not query:
await params.result_callback(
{"status": "error", "message": "检索问题为空"}
)
return
try:
async with SessionLocal() as session:
results = await search_knowledge(
session,
cfg.knowledge_base_id or "",
query,
top_k=int(
knowledge_config.get(
"top_n", knowledge_config.get("topN", 5)
)
),
score_threshold=float(
knowledge_config.get(
"score_threshold",
knowledge_config.get("scoreThreshold", 0.0),
)
),
)
await params.result_callback({"status": "ok", "results": results})
except Exception as exc: # noqa: BLE001
logger.warning(f"Responses knowledge search failed: {exc}")
await params.result_callback(
{"status": "error", "message": "知识库检索暂时不可用"}
)
llm.register_function("search_knowledge_base", search_bound_knowledge)
def set_tools(schemas=None) -> None:
visible = list(schemas or [])
if knowledge_schema is not None:
visible.append(knowledge_schema)
if visible:
context.set_tools(ToolsSchema(standard_tools=visible))
else:
context.set_tools()
await brain.setup(
cfg,
BrainRuntime(
context=context,
llm=llm,
queue_frame=worker.queue_frame,
set_system_prompt=set_system_prompt,
set_tools=set_tools,
call_end=call_end,
session_id=cfg.conversation_id,
client_tools=client_tools,
worker=worker,
context_aggregator=aggregators,
transport=None,
switch_services=switch_services,
set_knowledge_scope=knowledge.set_scope,
set_vision_scope=lambda scope: workflow_vision_scope.update(scope),
vision_function=None,
set_input_enabled=lambda _enabled: None,
apply_turn_config=lambda _enabled, _config: asyncio.sleep(0),
flow_global_functions=[],
),
)
greeting = str(await brain.greeting(cfg) or "").strip()
if greeting and brain.spec.owns_context:
brain.prepare_greeting_context(greeting, context)
@assistant_aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(_aggregator):
capture.window.active_assistant += 1
capture.window.touch()
await brain.on_assistant_text_start(new_id("turn"))
@assistant_aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(_aggregator, message):
text = str(message.content or "").strip()
capture.window.active_assistant = max(
0, capture.window.active_assistant - 1
)
if text:
capture.outputs.append(text)
capture.window.touch()
await brain.on_assistant_text_end(
new_id("turn"),
text,
bool(getattr(message, "interrupted", False)),
)
if call_end.ending:
await call_end.finish()
runner_task: asyncio.Task[None] | None = None
try:
await brain.run_preflight()
await runner.add_workers(worker)
runner_task = asyncio.create_task(
runner.run(), name=f"responses-{new_id('run')[:20]}"
)
await asyncio.wait_for(
worker_started.wait(), timeout=WORKER_START_TIMEOUT_SECONDS
)
await brain.on_connected(greeting_pending=False)
await brain.on_client_ready()
boundary = asyncio.Event()
await worker.queue_frame(_BoundaryFrame(reached=boundary))
await asyncio.wait_for(boundary.wait(), timeout=WORKER_START_TIMEOUT_SECONDS)
startup_outcome = await _wait_until_idle(
capture,
runner_task,
require_activity=False,
client_tools=client_tools,
)
if startup_outcome == "function_call":
startup_text = (
capture.outputs[-1]
if capture.outputs
else "".join(capture.deltas)
).strip()
return ResponseRunResult(
text=startup_text,
function_calls=list(client_tools.calls),
context_messages=_context_snapshot(
context,
function_calls=client_tools.calls,
fallback_messages=current_messages,
),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
)
capture.reset()
await worker.queue_frame(
LLMMessagesAppendFrame(messages=current_messages, run_llm=True)
)
outcome = await _wait_until_idle(
capture,
runner_task,
require_activity=True,
client_tools=client_tools,
)
if outcome == "function_call":
text = (
capture.outputs[-1]
if capture.outputs
else "".join(capture.deltas)
).strip()
return ResponseRunResult(
text=text,
function_calls=list(client_tools.calls),
context_messages=_context_snapshot(
context,
function_calls=client_tools.calls,
),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
)
text = (capture.outputs[-1] if capture.outputs else "".join(capture.deltas)).strip()
if not text:
raise ResponseRunError("Model returned no text")
return ResponseRunResult(
text=text,
function_calls=[],
context_messages=_context_snapshot(context, function_calls=[]),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
)
finally:
if not worker.has_finished():
await worker.cancel(reason="responses_turn_complete")
if runner_task is not None:
await asyncio.gather(runner_task, return_exceptions=True)
+472
View File
@@ -0,0 +1,472 @@
"""Persistence and context reconstruction for stateful Responses calls."""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from db.models import ConversationMessage, ConversationSession, OpenAIResponse
from db.session import SessionLocal
from models import AssistantConfig
from services.openai_realtime.events import assistant_id_from_model
from services.openai_responses.protocol import (
ResponseRequest,
ResponsesAPIError,
new_id,
)
from services.realtime.launcher import resolve_assistant_config
from sqlalchemy import func, select, update
CONTINUABLE_STATUSES = {"completed"}
@dataclass(frozen=True)
class PreparedResponse:
row: OpenAIResponse
config: AssistantConfig
history_messages: list[dict[str, Any]]
current_messages: list[dict[str, Any]]
runtime_state: dict[str, Any]
client_tool_results: list[dict[str, Any]]
def _content_text(item: dict[str, Any]) -> str:
return "\n".join(
str(part.get("text") or "")
for part in item.get("content") or []
if isinstance(part, dict)
).strip()
def items_to_messages(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert the supported Responses item subset to OpenAI LLM messages."""
messages: list[dict[str, Any]] = []
pending_calls: list[dict[str, Any]] = []
def flush_calls() -> None:
if not pending_calls:
return
messages.append(
{"role": "assistant", "content": "", "tool_calls": list(pending_calls)}
)
pending_calls.clear()
for item in items:
item_type = item.get("type")
if item_type == "function_call":
pending_calls.append(
{
"id": str(item.get("call_id") or ""),
"type": "function",
"function": {
"name": str(item.get("name") or ""),
"arguments": str(item.get("arguments") or "{}"),
},
}
)
continue
flush_calls()
if item_type == "message":
messages.append(
{
"role": str(item.get("role") or "user"),
"content": _content_text(item),
}
)
elif item_type == "function_call_output":
messages.append(
{
"role": "tool",
"tool_call_id": str(item.get("call_id") or ""),
"content": str(item.get("output") or ""),
}
)
flush_calls()
return messages
async def _load_chain(
response_id: str,
*,
api_key_id: str,
) -> list[OpenAIResponse]:
chain: list[OpenAIResponse] = []
seen: set[str] = set()
async with SessionLocal() as db:
current_id: str | None = response_id
while current_id:
if current_id in seen or len(chain) >= 1000:
raise ResponsesAPIError(
"Response chain is invalid", code="invalid_response_chain"
)
seen.add(current_id)
row = await db.get(OpenAIResponse, current_id)
if row is None or row.api_key_id != api_key_id:
raise ResponsesAPIError(
"Response not found", code="response_not_found", status_code=404
)
chain.append(row)
current_id = row.previous_response_id
chain.reverse()
return chain
def _validate_tool_outputs(
chain: list[OpenAIResponse],
current_items: list[dict[str, Any]],
) -> None:
unresolved: dict[str, dict[str, Any]] = {}
for row in chain:
for item in row.output_items or []:
if item.get("type") == "function_call":
unresolved[str(item.get("call_id") or "")] = item
for item in row.input_items or []:
if item.get("type") == "function_call_output":
unresolved.pop(str(item.get("call_id") or ""), None)
supplied = [
str(item.get("call_id") or "")
for item in current_items
if item.get("type") == "function_call_output"
]
if len(set(supplied)) != len(supplied):
raise ResponsesAPIError(
"A function call output can only be supplied once",
code="duplicate_tool_output",
param="input",
)
unknown = sorted(set(supplied) - set(unresolved))
if unknown:
raise ResponsesAPIError(
f"Unknown or already resolved call_id: {unknown[0]}",
code="invalid_tool_output",
param="input",
)
if unresolved and set(supplied) != set(unresolved):
missing = sorted(set(unresolved) - set(supplied))
raise ResponsesAPIError(
f"Missing function_call_output for call_id: {missing[0]}",
code="missing_tool_output",
param="input",
)
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
for row in chain
for item in row.output_items or []
if item.get("type") == "function_call"
}
results: list[dict[str, Any]] = []
for item in current_items:
if item.get("type") != "function_call_output":
continue
call = calls.get(str(item.get("call_id") or "")) or {}
output = str(item.get("output") or "")
try:
value = json.loads(output)
except json.JSONDecodeError:
value = output
if not isinstance(value, dict) or "status" not in value:
value = {"status": "ok", "data": value}
results.append(
{
"call_id": item.get("call_id"),
"name": call.get("name"),
"result": value,
}
)
return results
async def prepare_response(
request: ResponseRequest,
*,
api_key_id: str,
) -> PreparedResponse:
chain: list[OpenAIResponse] = []
parent: OpenAIResponse | None = None
if request.previous_response_id:
chain = await _load_chain(
request.previous_response_id,
api_key_id=api_key_id,
)
parent = chain[-1]
if parent.status not in CONTINUABLE_STATUSES:
raise ResponsesAPIError(
f"Previous response is {parent.status}",
code="response_not_ready",
param="previous_response_id",
status_code=409,
)
if (parent.runtime_state or {}).get("status") == "ended":
raise ResponsesAPIError(
"The workflow conversation has ended",
code="conversation_ended",
param="previous_response_id",
status_code=409,
)
if not parent.assistant_id:
raise ResponsesAPIError(
"The assistant used by the previous response no longer exists",
code="invalid_model",
param="previous_response_id",
)
assistant_id = parent.assistant_id
if request.model:
requested_id = assistant_id_from_model(request.model)
if requested_id != assistant_id:
raise ResponsesAPIError(
"model must match the previous response",
code="invalid_model",
param="model",
)
conversation_id = parent.conversation_id
variables = dict(parent.dynamic_variables or {})
variables.update(request.dynamic_variables)
_validate_tool_outputs(chain, request.input_items)
client_tool_results = _client_tool_results(chain, request.input_items)
runtime_state = dict(parent.runtime_state or {})
else:
try:
assistant_id = assistant_id_from_model(request.model)
except ValueError as exc:
raise ResponsesAPIError(
str(exc), code="invalid_model", param="model"
) from exc
conversation_id = new_id("conv")[:40]
variables = dict(request.dynamic_variables)
client_tool_results = []
runtime_state = {}
if any(
item.get("type") == "function_call_output"
for item in request.input_items
):
raise ResponsesAPIError(
"function_call_output requires previous_response_id",
code="invalid_tool_output",
param="input",
)
try:
config = await resolve_assistant_config(
assistant_id,
dynamic_variables=variables,
conversation_id=conversation_id,
)
except ValueError as exc:
raise ResponsesAPIError(
str(exc), code="invalid_model", param="model"
) from exc
if config.runtimeMode != "pipeline":
raise ResponsesAPIError(
"Responses API only supports pipeline assistants",
code="unsupported_runtime_mode",
param="model",
)
if config.type not in {"prompt", "workflow", "dify", "fastgpt"}:
raise ResponsesAPIError(
f"Assistant type {config.type} is not supported by Responses API",
code="unsupported_assistant_type",
param="model",
)
if config.conversation_id != conversation_id:
config = config.model_copy(update={"conversation_id": conversation_id})
effective_variables = {
name: value
for name, value in (config.dynamic_variables or {}).items()
if not name.startswith(("system__", "secret__"))
}
history_items: list[dict[str, Any]] = []
for item in chain:
history_items.extend(item.input_items or [])
history_items.extend(item.output_items or [])
history_messages = (
list(parent.context_messages or [])
if parent is not None and parent.context_messages
else items_to_messages(history_items)
)
row = OpenAIResponse(
id=new_id("resp")[:40],
api_key_id=api_key_id,
assistant_id=assistant_id,
conversation_id=conversation_id,
previous_response_id=request.previous_response_id,
status="in_progress",
input_items=request.input_items,
output_items=[],
context_messages=[],
runtime_state={},
dynamic_variables=effective_variables,
)
async with SessionLocal() as db:
if parent is None:
db.add(
ConversationSession(
id=conversation_id,
assistant_id=assistant_id,
assistant_name=config.name,
channel="responses",
runtime_mode="pipeline",
status="active",
message_count=0,
analysis_status="none",
analysis_data=(
{"plan": config.analysis_config}
if config.analysis_config.get("enabled")
else {}
),
extra={},
)
)
else:
conversation = await db.get(ConversationSession, conversation_id)
if conversation:
conversation.status = "active"
conversation.ended_at = None
db.add(row)
await db.commit()
await db.refresh(row)
return PreparedResponse(
row=row,
config=config,
history_messages=history_messages,
current_messages=items_to_messages(request.input_items),
runtime_state=runtime_state,
client_tool_results=client_tool_results,
)
async def get_response(
response_id: str,
*,
api_key_id: str,
) -> 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:
raise ResponsesAPIError(
"Response not found", code="response_not_found", status_code=404
)
return row
async def finish_response(
response_id: str,
*,
status: str,
output_items: list[dict[str, Any]],
context_messages: list[dict[str, Any]] | None = None,
dynamic_variables: dict[str, Any] | None = None,
runtime_state: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
) -> OpenAIResponse:
now = datetime.now(UTC)
async with SessionLocal() as db:
row = await db.get(OpenAIResponse, response_id)
if row is None:
raise RuntimeError(f"Response disappeared while running: {response_id}")
row.status = status
row.output_items = output_items
row.context_messages = context_messages or []
if dynamic_variables is not None:
row.dynamic_variables = dynamic_variables
if runtime_state is not None:
row.runtime_state = runtime_state
row.error = error
row.completed_at = now
conversation = (
await db.execute(
select(ConversationSession)
.where(ConversationSession.id == row.conversation_id)
.with_for_update()
)
).scalar_one_or_none()
if conversation:
max_sequence = await db.scalar(
select(func.max(ConversationMessage.sequence)).where(
ConversationMessage.session_id == row.conversation_id
)
)
sequence = int(max_sequence or 0)
for item in row.input_items or []:
if item.get("type") != "message" or item.get("role") != "user":
continue
text = _content_text(item)
if not text:
continue
sequence += 1
db.add(
ConversationMessage(
id=new_id("msg")[:40],
session_id=row.conversation_id,
sequence=sequence,
role="user",
content_type="text",
content=text,
extra={"response_id": row.id},
)
)
for item in output_items:
if item.get("type") == "message":
text = _content_text(item)
elif item.get("type") == "function_call":
text = f"[function_call] {item.get('name') or ''}"
else:
continue
if not text:
continue
sequence += 1
db.add(
ConversationMessage(
id=new_id("msg")[:40],
session_id=row.conversation_id,
sequence=sequence,
role="assistant",
content_type="text",
content=text,
extra={"response_id": row.id, "item": item},
)
)
conversation.message_count = sequence
conversation.status = "completed" if status == "completed" else status
conversation.ended_at = now
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)
async with SessionLocal() as db:
conversation_ids = select(OpenAIResponse.conversation_id).where(
OpenAIResponse.status == "in_progress"
)
await db.execute(
update(ConversationSession)
.where(ConversationSession.id.in_(conversation_ids))
.values(status="cancelled", ended_at=now)
)
await db.execute(
update(OpenAIResponse)
.where(OpenAIResponse.status == "in_progress")
.values(
status="cancelled",
error={
"code": "server_restarted",
"message": "Response was interrupted by a server restart",
},
completed_at=now,
)
)
await db.commit()
+2
View File
@@ -17,6 +17,7 @@ async def resolve_assistant_config(
assistant_id: str,
*,
dynamic_variables: dict[str, Any] | None = None,
conversation_id: str | None = None,
) -> AssistantConfig:
async with SessionLocal() as session:
config = await resolve_runtime_config(session, assistant_id)
@@ -24,6 +25,7 @@ async def resolve_assistant_config(
config,
dynamic_variables or {},
assistant_id=assistant_id,
conversation_id=conversation_id,
)
+2 -1
View File
@@ -256,6 +256,7 @@ def prepare_dynamic_config(
client_values: dict[str, Any] | None,
*,
assistant_id: str | None,
conversation_id: str | None = None,
timezone: str = "Asia/Shanghai",
trusted_values: dict[str, Any] | None = None,
) -> AssistantConfig:
@@ -288,7 +289,7 @@ def prepare_dynamic_config(
merged[name] = value
merged.update(supplied)
conversation_id = f"conv_{uuid4().hex[:20]}"
conversation_id = conversation_id or f"conv_{uuid4().hex[:20]}"
merged.update(
_system_values(
assistant_id=assistant_id,
+3
View File
@@ -45,6 +45,9 @@ REALTIME_TOKEN_SECRET = os.getenv("REALTIME_TOKEN_SECRET", AUTH_SECRET_KEY)
REALTIME_CLIENT_SECRET_TTL_SECONDS = int(
os.getenv("REALTIME_CLIENT_SECRET_TTL_SECONDS", "60")
)
RESPONSES_REQUEST_TIMEOUT_SECONDS = int(
os.getenv("RESPONSES_REQUEST_TIMEOUT_SECONDS", "120")
)
# ---- WebRTC STUN / TURN ----
# Override STUN_URL in remote deployments to use the colocated coturn server
+29
View File
@@ -315,6 +315,35 @@ class DifyLLMServiceTests(unittest.IsolatedAsyncioTestCase):
class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_responses_state_skips_completed_prompt_startup(self):
cfg = AssistantConfig(type="prompt")
brain = build_brain(cfg)
brain.restore_text_state(
{
"type": "prompt",
"preflight_finished": True,
"opening_finished": True,
"entry_dispatched": True,
}
)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
self.assertTrue(brain._preflight_finished)
self.assertTrue(brain._opening_finished)
self.assertTrue(brain._entry_dispatched)
self.assertEqual(brain.text_state_snapshot()["type"], "prompt")
async def test_startup_only_tool_is_not_registered_with_llm(self):
startup_tool = RuntimeTool(
id="opening_message",
+355
View File
@@ -0,0 +1,355 @@
from __future__ import annotations
import asyncio
import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from models import AssistantConfig
from pipecat.frames.frames import (
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMTextFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from services.brains.base import BaseBrain, BrainSpec
from services.openai_responses.protocol import ResponsesAPIError, parse_request
from services.openai_responses.runner import (
ResponsesClientToolPort,
ResponsesTextRunner,
_context_snapshot,
)
from services.openai_responses.state import _validate_tool_outputs, items_to_messages
from routes.openai_responses import _stream_response
class ResponsesProtocolTests(unittest.TestCase):
def test_first_turn_requires_model_and_normalizes_text(self):
request = parse_request(
{
"model": "assistant:asst_1",
"input": "你好",
"x_interactive_media": {"variables": {"customer": "王女士"}},
}
)
self.assertEqual(request.model, "assistant:asst_1")
self.assertFalse(request.stream)
self.assertEqual(request.dynamic_variables, {"customer": "王女士"})
self.assertEqual(request.input_items[0]["role"], "user")
self.assertEqual(request.input_items[0]["content"][0]["text"], "你好")
def test_continuation_can_inherit_model(self):
request = parse_request(
{
"previous_response_id": "resp_parent",
"input": [
{
"type": "function_call_output",
"call_id": "call_1",
"output": {"ok": True},
}
],
"stream": True,
}
)
self.assertIsNone(request.model)
self.assertTrue(request.stream)
self.assertEqual(request.input_items[0]["output"], '{"ok": true}')
def test_rejects_stateless_and_assistant_owned_options(self):
with self.assertRaisesRegex(ResponsesAPIError, "store must be true"):
parse_request(
{"model": "assistant:asst_1", "input": "hi", "store": False}
)
with self.assertRaisesRegex(ResponsesAPIError, "store must be true"):
parse_request(
{"model": "assistant:asst_1", "input": "hi", "store": None}
)
with self.assertRaisesRegex(ResponsesAPIError, "owned"):
parse_request(
{
"model": "assistant:asst_1",
"input": "hi",
"instructions": "override",
}
)
def test_rejects_unknown_top_level_parameters(self):
with self.assertRaises(ResponsesAPIError) as raised:
parse_request(
{"model": "assistant:asst_1", "input": "hi", "metadata": {}}
)
self.assertEqual(raised.exception.code, "unsupported_parameter")
self.assertEqual(raised.exception.param, "metadata")
def test_rejects_images_and_unknown_items(self):
with self.assertRaises(ResponsesAPIError) as raised:
parse_request(
{
"model": "assistant:asst_1",
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_image", "image_url": "x"}],
}
],
}
)
self.assertEqual(raised.exception.code, "invalid_request_error")
class ResponsesStateTests(unittest.TestCase):
def test_context_items_become_llm_messages(self):
messages = items_to_messages(
[
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "查订单"}],
},
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup_order",
"arguments": '{"id":"A1"}',
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": '{"status":"ok"}',
},
]
)
self.assertEqual(messages[0], {"role": "user", "content": "查订单"})
self.assertEqual(messages[1]["tool_calls"][0]["id"], "call_1")
self.assertEqual(messages[2]["role"], "tool")
def test_tool_output_must_resolve_parent_pending_call(self):
parent = SimpleNamespace(
output_items=[{"type": "function_call", "call_id": "call_1"}],
input_items=[],
)
_validate_tool_outputs(
[parent],
[
{
"type": "function_call_output",
"call_id": "call_1",
"output": "ok",
}
],
)
with self.assertRaises(ResponsesAPIError) as raised:
_validate_tool_outputs(
[parent],
[
{
"type": "function_call_output",
"call_id": "call_other",
"output": "ok",
}
],
)
self.assertEqual(raised.exception.code, "invalid_tool_output")
def test_context_snapshot_aligns_provider_and_public_call_ids(self):
context = SimpleNamespace(
get_messages=lambda: [
{
"role": "assistant",
"tool_calls": [
{
"id": "provider_call",
"type": "function",
"function": {"name": "show_message", "arguments": "{}"},
}
],
}
]
)
snapshot = _context_snapshot(
context,
function_calls=[
{
"call_id": "call_public",
"name": "show_message",
"arguments": "{}",
}
],
)
self.assertEqual(snapshot[0]["tool_calls"][0]["id"], "call_public")
def test_tool_output_cannot_be_replayed(self):
resolved = SimpleNamespace(
output_items=[{"type": "function_call", "call_id": "call_1"}],
input_items=[
{
"type": "function_call_output",
"call_id": "call_1",
"output": "ok",
}
],
)
with self.assertRaises(ResponsesAPIError) as raised:
_validate_tool_outputs(
[resolved],
[
{
"type": "function_call_output",
"call_id": "call_1",
"output": "again",
}
],
)
self.assertEqual(raised.exception.code, "invalid_tool_output")
class ResponsesClientToolTests(unittest.IsolatedAsyncioTestCase):
async def test_client_tool_is_exposed_without_waiting_for_browser(self):
port = ResponsesClientToolPort()
task = asyncio.create_task(
port.call("show_message", {"message": "确认"}, timeout_seconds=3)
)
await port.called.wait()
self.assertEqual(port.calls[0]["type"], "function_call")
self.assertEqual(port.calls[0]["name"], "show_message")
self.assertIn('"message": "确认"', port.calls[0]["arguments"])
task.cancel()
await asyncio.gather(task, return_exceptions=True)
async def test_persisted_result_resumes_deterministic_client_tool(self):
port = ResponsesClientToolPort(
[
{
"name": "show_message",
"result": {"status": "ok", "data": {"action": "confirmed"}},
}
]
)
result = await port.call(
"show_message",
{"message": "确认"},
timeout_seconds=3,
)
self.assertEqual(result["data"]["action"], "confirmed")
self.assertFalse(port.called.is_set())
class _FakeTextLLM(FrameProcessor):
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if not isinstance(frame, LLMContextFrame):
await self.push_frame(frame, direction)
return
await self.push_frame(LLMFullResponseStartFrame())
await self.push_frame(LLMTextFrame("状态已恢复"))
await self.push_frame(LLMFullResponseEndFrame())
class _FakeBrain(BaseBrain):
spec = BrainSpec(
type="dify",
supported_runtime_modes=frozenset({"pipeline"}),
owns_context=False,
)
def build_llm(self, _cfg, _context):
return _FakeTextLLM()
class ResponsesTextRunnerTests(unittest.IsolatedAsyncioTestCase):
async def test_transportless_runner_streams_and_finishes_text(self):
deltas: list[str] = []
async def on_delta(value: str) -> None:
deltas.append(value)
with patch(
"services.openai_responses.runner.build_brain",
return_value=_FakeBrain(),
):
result = await asyncio.wait_for(
ResponsesTextRunner().run(
AssistantConfig(
type="dify",
runtimeMode="pipeline",
greeting="",
),
history_messages=[{"role": "user", "content": "上一轮"}],
current_messages=[{"role": "user", "content": "继续"}],
on_delta=on_delta,
),
timeout=3,
)
self.assertEqual(result.text, "状态已恢复")
self.assertEqual(deltas, ["状态已恢复"])
class ResponsesStreamingTests(unittest.IsolatedAsyncioTestCase):
async def test_stream_uses_responses_event_lifecycle(self):
now = datetime.now(UTC)
row = SimpleNamespace(
id="resp_1",
api_key_id="rtkey_1",
assistant_id="asst_1",
previous_response_id=None,
status="completed",
error=None,
output_items=[
{
"id": "msg_1",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "完成",
"annotations": [],
}
],
}
],
created_at=now,
completed_at=now,
)
prepared = SimpleNamespace(row=row)
async def run_prepared(_prepared, *, on_delta, text_item_id):
self.assertEqual(text_item_id, "msg_1")
await on_delta("完")
await on_delta("成")
return row
with patch(
"routes.openai_responses._run_prepared",
new=AsyncMock(side_effect=run_prepared),
):
chunks = [
chunk.decode("utf-8")
async for chunk in _stream_response(prepared, text_item_id="msg_1")
]
body = "".join(chunks)
self.assertIn("event: response.created", body)
self.assertIn("event: response.output_text.delta", body)
self.assertIn("event: response.output_text.done", body)
self.assertIn("event: response.completed", body)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -46,7 +46,7 @@
"groups": [
{
"group": "开始与对接",
"pages": ["index", "integrations", "tool-resources", "realtime-protocol"]
"pages": ["index", "integrations", "tool-resources", "responses-api", "realtime-protocol"]
}
]
}
+2 -1
View File
@@ -40,8 +40,9 @@ make db-seed
保存后,从助手详情页打开语音预览即可测试。
## 4. 对接 Realtime 协议
## 4. 对接公开协议
- [Responses 文本接口](/responses-api):通过 HTTP 或 SSE 调用 Pipeline 助手,并使用 `previous_response_id` 延续多轮状态。
- [Realtime 兼容协议设计](/realtime-protocol):了解后端提供的 OpenAI Realtime 兼容 WebRTC、WebSocket、鉴权与 Interactive Media 扩展协议。
<Note>
+135
View File
@@ -0,0 +1,135 @@
---
title: Stateful Responses 文本接口
description: 使用 OpenAI Responses 风格的 HTTP 与 SSE 接口调用平台助手。
---
# Stateful Responses 文本接口
`POST /v1/responses` 为 Pipeline 助手提供文本调用。服务端保存每轮输入和输出,后续请求通过 `previous_response_id` 恢复上下文。语音、视频和长连接交互继续使用 `/v1/realtime`。
接口使用后台创建的长期 `sk-rt-...` API Key。短期 `ek-rt-...` Client Secret 不能调用文本接口。
## 首轮请求
```bash
curl -X POST "https://api.example.com/v1/responses" \
-H "Authorization: Bearer $REALTIME_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "assistant:asst_xxx",
"input": "请介绍一下这个产品"
}'
```
响应中的 `id` 是下一轮使用的状态指针:
```json
{
"id": "resp_xxx",
"object": "response",
"status": "completed",
"model": "assistant:asst_xxx",
"previous_response_id": null,
"output": [
{
"id": "msg_xxx",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [
{"type": "output_text", "text": "……", "annotations": []}
]
}
]
}
```
## 继续多轮对话
后续请求可以省略 `model`。服务端从父响应恢复助手、动态变量、消息和工具上下文:
```bash
curl -X POST "https://api.example.com/v1/responses" \
-H "Authorization: Bearer $REALTIME_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"previous_response_id": "resp_xxx",
"input": "它适合哪些行业?"
}'
```
同一个父响应可以创建多个独立分支。Response 只能由创建它的 API Key 查询或继续:
```bash
curl "https://api.example.com/v1/responses/resp_xxx" \
-H "Authorization: Bearer $REALTIME_API_KEY"
```
## SSE 流式输出
设置 `stream: true` 后返回 `text/event-stream`:
```json
{
"model": "assistant:asst_xxx",
"input": "给我一个简短介绍",
"stream": true
}
```
主要事件依次为 `response.created`、`response.in_progress`、`response.output_text.delta`、`response.output_text.done` 和 `response.completed`。连接中断会取消运行,并把 Response 标记为 `cancelled`。
## 动态变量
首轮可以传入项目扩展字段,后续请求会继承并可增量覆盖:
```json
{
"model": "assistant:asst_xxx",
"input": "查询我的权益",
"x_interactive_media": {
"variables": {
"customer_name": "王女士",
"membership_level": "gold"
}
}
}
```
## Client Tool 续传
当助手需要调用客户端工具时,Response 的 `output` 会包含 `function_call`:
```json
{
"type": "function_call",
"call_id": "call_xxx",
"name": "show_message",
"arguments": "{\"message\":\"请确认\"}"
}
```
客户端执行后,通过父响应和对应的 `call_id` 继续:
```json
{
"previous_response_id": "resp_xxx",
"input": [
{
"type": "function_call_output",
"call_id": "call_xxx",
"output": "{\"status\":\"ok\",\"data\":{\"action\":\"confirmed\"}}"
}
]
}
```
如果父链存在多个尚未完成的 Client Tool,本轮必须一次提交全部对应结果。
## 首版边界
- 仅支持 `runtimeMode=pipeline` 的 Prompt、Workflow、Dify 和 FastGPT 助手。
- 只支持文本 `message` 和 `function_call_output` 输入。
- `store` 必须为 `true` 或省略。
- 不支持 `conversation`、`background`、图片、文件以及调用方覆盖 instructions 或 tools。
- 不提供 `/v1/chat/completions` 兼容接口。