feat: add stateful responses API
This commit is contained in:
@@ -18,6 +18,7 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
|||||||
# 用独立随机值签名长期 API Key 摘要和短期 Client Secret;生产环境必须修改。
|
# 用独立随机值签名长期 API Key 摘要和短期 Client Secret;生产环境必须修改。
|
||||||
REALTIME_TOKEN_SECRET=replace-with-a-long-random-secret
|
REALTIME_TOKEN_SECRET=replace-with-a-long-random-secret
|
||||||
REALTIME_CLIENT_SECRET_TTL_SECONDS=60
|
REALTIME_CLIENT_SECRET_TTL_SECONDS=60
|
||||||
|
RESPONSES_REQUEST_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
# ---- RustFS / S3-compatible storage ----
|
# ---- RustFS / S3-compatible storage ----
|
||||||
# Use 127.0.0.1 (not localhost) if HTTP_PROXY is set — proxies often return 502 for localhost.
|
# Use 127.0.0.1 (not localhost) if HTTP_PROXY is set — proxies often return 502 for localhost.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import settings
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
from db.session import sync_default_tools, sync_interface_definitions
|
from db.session import sync_default_tools, sync_interface_definitions
|
||||||
from services.knowledge import recover_interrupted_documents
|
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.post_call.worker import recover_interrupted_analyses, run_analysis_worker
|
||||||
from services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker
|
from services.webhooks.worker import recover_interrupted_webhooks, run_webhook_worker
|
||||||
from services.test_runs.orchestrator import (
|
from services.test_runs.orchestrator import (
|
||||||
@@ -45,6 +46,7 @@ from routes import (
|
|||||||
model_registry,
|
model_registry,
|
||||||
node_types,
|
node_types,
|
||||||
openai_realtime,
|
openai_realtime,
|
||||||
|
openai_responses,
|
||||||
realtime_api_keys,
|
realtime_api_keys,
|
||||||
site_settings,
|
site_settings,
|
||||||
test_cases,
|
test_cases,
|
||||||
@@ -64,6 +66,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
await recover_interrupted_analyses()
|
await recover_interrupted_analyses()
|
||||||
await recover_interrupted_webhooks()
|
await recover_interrupted_webhooks()
|
||||||
await recover_interrupted_test_runs()
|
await recover_interrupted_test_runs()
|
||||||
|
await recover_interrupted_responses()
|
||||||
analysis_worker = asyncio.create_task(
|
analysis_worker = asyncio.create_task(
|
||||||
run_analysis_worker(), name="post-call-analysis-worker"
|
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(model_registry.router)
|
||||||
app.include_router(node_types.router)
|
app.include_router(node_types.router)
|
||||||
app.include_router(openai_realtime.router)
|
app.include_router(openai_realtime.router)
|
||||||
|
app.include_router(openai_responses.router)
|
||||||
app.include_router(realtime_api_keys.router)
|
app.include_router(realtime_api_keys.router)
|
||||||
app.include_router(site_settings.router)
|
app.include_router(site_settings.router)
|
||||||
app.include_router(test_cases.router)
|
app.include_router(test_cases.router)
|
||||||
|
|||||||
@@ -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):
|
class WebhookDelivery(Base):
|
||||||
"""Immutable event snapshot plus its asynchronous delivery state."""
|
"""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")
|
||||||
@@ -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)
|
||||||
@@ -24,6 +24,8 @@ class DifyBrain(BaseBrain):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._user_id = f"ai-video-{uuid4().hex}"
|
self._user_id = f"ai-video-{uuid4().hex}"
|
||||||
self._client: AsyncClient | None = None
|
self._client: AsyncClient | None = None
|
||||||
|
self._llm: DifyLLMService | None = None
|
||||||
|
self._restored_conversation_id = ""
|
||||||
|
|
||||||
def _get_client(self, cfg: AssistantConfig) -> AsyncClient:
|
def _get_client(self, cfg: AssistantConfig) -> AsyncClient:
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
@@ -54,8 +56,25 @@ class DifyBrain(BaseBrain):
|
|||||||
return cfg.greeting
|
return cfg.greeting
|
||||||
|
|
||||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||||
return DifyLLMService(
|
self._llm = DifyLLMService(
|
||||||
cfg,
|
cfg,
|
||||||
client=self._get_client(cfg),
|
client=self._get_client(cfg),
|
||||||
user_id=self._user_id,
|
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 ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class DifyLLMService(LLMService):
|
|||||||
*,
|
*,
|
||||||
client: AsyncClient | None = None,
|
client: AsyncClient | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
|
conversation_id: str = "",
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
settings=LLMSettings(
|
settings=LLMSettings(
|
||||||
@@ -75,7 +76,11 @@ class DifyLLMService(LLMService):
|
|||||||
api_base=normalize_api_base(cfg.dify_api_url),
|
api_base=normalize_api_base(cfg.dify_api_url),
|
||||||
)
|
)
|
||||||
self._user_id = user_id or f"ai-video-{uuid4().hex}"
|
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):
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
await super().process_frame(frame, direction)
|
await super().process_frame(frame, direction)
|
||||||
|
|||||||
@@ -66,3 +66,11 @@ class FastGPTBrain(BaseBrain):
|
|||||||
|
|
||||||
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
|
||||||
return FastGPTLLMService(cfg, chat_id=self._chat_id)
|
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}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class PromptBrain(BaseBrain):
|
|||||||
self._startup_failed = False
|
self._startup_failed = False
|
||||||
self._greeting_pending = False
|
self._greeting_pending = False
|
||||||
self._entry_dispatched = False
|
self._entry_dispatched = False
|
||||||
|
self._restored_text_state: dict[str, Any] | None = None
|
||||||
|
|
||||||
async def greeting(self, cfg: AssistantConfig) -> str:
|
async def greeting(self, cfg: AssistantConfig) -> str:
|
||||||
# The built-in opening Message owns the greeting so speech and the
|
# The built-in opening Message owns the greeting so speech and the
|
||||||
@@ -127,6 +128,12 @@ class PromptBrain(BaseBrain):
|
|||||||
self._startup_failed = False
|
self._startup_failed = False
|
||||||
self._greeting_pending = False
|
self._greeting_pending = False
|
||||||
self._entry_dispatched = 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] = []
|
schemas: list[FunctionSchema] = []
|
||||||
registered_names: set[str] = set()
|
registered_names: set[str] = set()
|
||||||
binding_by_id = {binding.tool_id: binding for binding in cfg.tool_bindings}
|
binding_by_id = {binding.tool_id: binding for binding in cfg.tool_bindings}
|
||||||
@@ -171,6 +178,21 @@ class PromptBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
runtime.set_tools(schemas)
|
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(
|
async def setup_realtime(
|
||||||
self,
|
self,
|
||||||
cfg: AssistantConfig,
|
cfg: AssistantConfig,
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
self._next_message_token = 1
|
self._next_message_token = 1
|
||||||
self._pending_message: _MessageContinuation | None = None
|
self._pending_message: _MessageContinuation | None = None
|
||||||
self._realtime_controller: WorkflowRealtimeController | None = None
|
self._realtime_controller: WorkflowRealtimeController | None = None
|
||||||
|
self._restored_text_node_id: str | None = None
|
||||||
|
|
||||||
async def greeting(self, _cfg: AssistantConfig) -> str:
|
async def greeting(self, _cfg: AssistantConfig) -> str:
|
||||||
"""Workflow opening speech belongs to an explicit Message or Agent."""
|
"""Workflow opening speech belongs to an explicit Message or Agent."""
|
||||||
@@ -274,6 +275,18 @@ class WorkflowBrain(BaseBrain):
|
|||||||
if self._realtime_controller is not None:
|
if self._realtime_controller is not None:
|
||||||
await self._realtime_controller.start()
|
await self._realtime_controller.start()
|
||||||
return
|
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)
|
self._state.enter(self._engine.start_id, WorkflowStatus.STARTING)
|
||||||
await self._emit_node_active(self._engine.start_id)
|
await self._emit_node_active(self._engine.start_id)
|
||||||
await self._emit_variables(
|
await self._emit_variables(
|
||||||
@@ -287,6 +300,33 @@ class WorkflowBrain(BaseBrain):
|
|||||||
await self._activate_node_config(node_config, initialize=True)
|
await self._activate_node_config(node_config, initialize=True)
|
||||||
logger.info(f"工作流模式启用: 当前节点={self._manager.current_node}")
|
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:
|
async def _initial_node_config(self) -> NodeConfig:
|
||||||
"""Only a default-only Start advances before the first user turn."""
|
"""Only a default-only Start advances before the first user turn."""
|
||||||
outgoing = self._engine.outgoing(self._engine.start_id)
|
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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -17,6 +17,7 @@ async def resolve_assistant_config(
|
|||||||
assistant_id: str,
|
assistant_id: str,
|
||||||
*,
|
*,
|
||||||
dynamic_variables: dict[str, Any] | None = None,
|
dynamic_variables: dict[str, Any] | None = None,
|
||||||
|
conversation_id: str | None = None,
|
||||||
) -> AssistantConfig:
|
) -> AssistantConfig:
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
config = await resolve_runtime_config(session, assistant_id)
|
config = await resolve_runtime_config(session, assistant_id)
|
||||||
@@ -24,6 +25,7 @@ async def resolve_assistant_config(
|
|||||||
config,
|
config,
|
||||||
dynamic_variables or {},
|
dynamic_variables or {},
|
||||||
assistant_id=assistant_id,
|
assistant_id=assistant_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ def prepare_dynamic_config(
|
|||||||
client_values: dict[str, Any] | None,
|
client_values: dict[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
assistant_id: str | None,
|
assistant_id: str | None,
|
||||||
|
conversation_id: str | None = None,
|
||||||
timezone: str = "Asia/Shanghai",
|
timezone: str = "Asia/Shanghai",
|
||||||
trusted_values: dict[str, Any] | None = None,
|
trusted_values: dict[str, Any] | None = None,
|
||||||
) -> AssistantConfig:
|
) -> AssistantConfig:
|
||||||
@@ -288,7 +289,7 @@ def prepare_dynamic_config(
|
|||||||
merged[name] = value
|
merged[name] = value
|
||||||
merged.update(supplied)
|
merged.update(supplied)
|
||||||
|
|
||||||
conversation_id = f"conv_{uuid4().hex[:20]}"
|
conversation_id = conversation_id or f"conv_{uuid4().hex[:20]}"
|
||||||
merged.update(
|
merged.update(
|
||||||
_system_values(
|
_system_values(
|
||||||
assistant_id=assistant_id,
|
assistant_id=assistant_id,
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ REALTIME_TOKEN_SECRET = os.getenv("REALTIME_TOKEN_SECRET", AUTH_SECRET_KEY)
|
|||||||
REALTIME_CLIENT_SECRET_TTL_SECONDS = int(
|
REALTIME_CLIENT_SECRET_TTL_SECONDS = int(
|
||||||
os.getenv("REALTIME_CLIENT_SECRET_TTL_SECONDS", "60")
|
os.getenv("REALTIME_CLIENT_SECRET_TTL_SECONDS", "60")
|
||||||
)
|
)
|
||||||
|
RESPONSES_REQUEST_TIMEOUT_SECONDS = int(
|
||||||
|
os.getenv("RESPONSES_REQUEST_TIMEOUT_SECONDS", "120")
|
||||||
|
)
|
||||||
|
|
||||||
# ---- WebRTC STUN / TURN ----
|
# ---- WebRTC STUN / TURN ----
|
||||||
# Override STUN_URL in remote deployments to use the colocated coturn server
|
# Override STUN_URL in remote deployments to use the colocated coturn server
|
||||||
|
|||||||
@@ -315,6 +315,35 @@ class DifyLLMServiceTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class PromptBrainTests(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):
|
async def test_startup_only_tool_is_not_registered_with_llm(self):
|
||||||
startup_tool = RuntimeTool(
|
startup_tool = RuntimeTool(
|
||||||
id="opening_message",
|
id="opening_message",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"group": "开始与对接",
|
"group": "开始与对接",
|
||||||
"pages": ["index", "integrations", "tool-resources", "realtime-protocol"]
|
"pages": ["index", "integrations", "tool-resources", "responses-api", "realtime-protocol"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 扩展协议。
|
- [Realtime 兼容协议设计](/realtime-protocol):了解后端提供的 OpenAI Realtime 兼容 WebRTC、WebSocket、鉴权与 Interactive Media 扩展协议。
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|||||||
@@ -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` 兼容接口。
|
||||||
Reference in New Issue
Block a user