Files
ai-video-fullstack/backend/services/pipecat/realtime_tools.py

129 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Provider-neutral function calling for speech-to-speech sessions."""
from __future__ import annotations
import asyncio
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from loguru import logger
@dataclass(frozen=True)
class RealtimeTool:
"""Small JSON-schema tool definition understood by both providers."""
name: str
description: str
properties: dict[str, Any] = field(default_factory=dict)
required: tuple[str, ...] = ()
def provider_schema(self) -> dict[str, Any]:
return {
"type": "function",
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": self.properties,
"required": list(self.required),
},
}
@dataclass(frozen=True)
class RealtimeToolResult:
"""Tool output plus whether the model should continue immediately."""
output: dict[str, Any]
continue_response: bool = True
after_output: Callable[[], Awaitable[None]] | None = None
RealtimeToolDispatcher = Callable[
[str, dict[str, Any], str], Awaitable[RealtimeToolResult]
]
SendProviderEvent = Callable[[dict[str, Any]], Awaitable[None]]
class RealtimeToolSession:
"""Serialize provider calls and answer every call id at most once."""
def __init__(self, send_event: SendProviderEvent) -> None:
self._send_event = send_event
self._dispatcher: RealtimeToolDispatcher | None = None
self._handled_call_ids: set[str] = set()
self._lock = asyncio.Lock()
def set_dispatcher(self, dispatcher: RealtimeToolDispatcher | None) -> None:
self._dispatcher = dispatcher
async def handle_call(
self,
*,
name: str,
call_id: str,
arguments: str | dict[str, Any] | None,
) -> None:
if not call_id:
logger.warning("Realtime function call 缺少 call_id已忽略")
return
async with self._lock:
if call_id in self._handled_call_ids:
return
self._handled_call_ids.add(call_id)
parsed = self._parse_arguments(arguments)
try:
if self._dispatcher is None:
result = RealtimeToolResult(
{"status": "error", "message": "当前会话未注册工具处理器"}
)
else:
result = await self._dispatcher(name, parsed, call_id)
except Exception as exc: # noqa: BLE001 - return tool errors to provider
logger.exception(f"Realtime 工具 {name} 执行失败:{exc}")
result = RealtimeToolResult(
{
"status": "error",
"message": f"工具执行失败:{type(exc).__name__}",
}
)
await self._send_event(
{
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps(
result.output,
ensure_ascii=False,
separators=(",", ":"),
),
},
}
)
if result.after_output is not None:
await result.after_output()
if result.continue_response:
await self._send_event({"type": "response.create"})
def clear(self) -> None:
self._handled_call_ids.clear()
@staticmethod
def _parse_arguments(
arguments: str | dict[str, Any] | None,
) -> dict[str, Any]:
if isinstance(arguments, dict):
return dict(arguments)
if not arguments:
return {}
try:
parsed = json.loads(arguments)
except (TypeError, json.JSONDecodeError):
return {}
return dict(parsed) if isinstance(parsed, dict) else {}