Files
ai-video-fullstack/backend/services/openai_responses/runner.py
T
Xin Wang 2649d64b55
Quality gate / backend (push) Canceled after 0s
Quality gate / frontend (push) Canceled after 0s
feat: harden public OpenAI API contracts
2026-09-07 14:25:48 +08:00

678 lines
25 KiB
Python

"""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,
MetricsFrame,
TTSSpeakFrame,
)
from pipecat.metrics.metrics import LLMTokenUsage, LLMUsageMetricsData
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
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]]
def _normalized_tool_arguments(value: object) -> object:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def _tool_arguments_equal(left: object, right: object) -> bool:
return _normalized_tool_arguments(left) == _normalized_tool_arguments(right)
@dataclass(frozen=True)
class ResponseRunResult:
text: str
function_calls: list[dict[str, Any]]
context_messages: list[dict[str, Any]]
dynamic_variables: dict[str, Any]
runtime_state: dict[str, Any]
usage: dict[str, Any] | None
def _usage_payload(values: list[LLMTokenUsage]) -> dict[str, Any] | None:
"""Combine Provider-reported LLM usage without estimating missing values."""
if not values:
return None
return {
"input_tokens": sum(value.prompt_tokens for value in values),
"input_tokens_details": {
"cached_tokens": sum(
value.cache_read_input_tokens or 0 for value in values
),
},
"output_tokens": sum(value.completion_tokens for value in values),
"output_tokens_details": {
"reasoning_tokens": sum(value.reasoning_tokens or 0 for value in values),
},
"total_tokens": sum(value.total_tokens for value in values),
}
@dataclass
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.llm_usage: list[LLMTokenUsage] = []
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, MetricsFrame):
self.llm_usage.extend(
metric.value
for metric in frame.data
if isinstance(metric, LLMUsageMetricsData)
)
elif isinstance(frame, _BoundaryFrame):
frame.reached.set()
self.window.touch()
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
@staticmethod
def _arguments_match(pending: dict[str, Any], arguments: dict[str, Any]) -> bool:
original = pending.get("arguments")
if original is None:
return True
return _tool_arguments_equal(original, arguments)
async def call(
self,
function_name: str,
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
and self._arguments_match(pending, arguments)
):
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))
candidates = [
tool_call
for message in messages
for tool_call in message.get("tool_calls") or []
]
used_candidates: set[int] = set()
for public_call in function_calls:
matched = False
for index, tool_call in enumerate(candidates):
if index in used_candidates:
continue
function = tool_call.get("function") or {}
if function.get("name") != public_call["name"]:
continue
if not _tool_arguments_equal(
function.get("arguments"),
public_call.get("arguments"),
):
continue
tool_call["id"] = public_call["call_id"]
used_candidates.add(index)
matched = True
break
if not matched:
messages.append(
{
"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_usage_metrics=True,
),
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),
usage=_usage_payload(capture.llm_usage),
)
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),
usage=_usage_payload(capture.llm_usage),
)
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),
usage=_usage_payload(capture.llm_usage),
)
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)