"""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 {}