From 0d4313acc14738f016b21727fc17ef98e837c36a Mon Sep 17 00:00:00 2001 From: Abat Date: Mon, 31 Aug 2026 18:22:54 +0500 Subject: [PATCH 01/17] feat: add Speko router extensions --- .../extension/speko_asr_python/README.md | 43 ++ .../extension/speko_asr_python/__init__.py | 6 + .../extension/speko_asr_python/addon.py | 15 + .../extension/speko_asr_python/client.py | 256 +++++++++++ .../extension/speko_asr_python/config.py | 97 ++++ .../extension/speko_asr_python/extension.py | 359 +++++++++++++++ .../extension/speko_asr_python/manifest.json | 72 +++ .../extension/speko_asr_python/property.json | 20 + .../extension/speko_asr_python/pyproject.toml | 9 + .../speko_asr_python/requirements.txt | 3 + .../speko_asr_python/tests/bin/start | 7 + .../tests/configs/property_dump.json | 8 + .../tests/configs/property_en.json | 8 + .../tests/configs/property_invalid.json | 6 + .../tests/configs/property_zh.json | 8 + .../speko_asr_python/tests/conftest.py | 7 + .../speko_asr_python/tests/test_client.py | 153 +++++++ .../extension/speko_asr_python/uv.lock | 240 ++++++++++ .../extension/speko_llm2_python/README.md | 40 ++ .../extension/speko_llm2_python/__init__.py | 6 + .../extension/speko_llm2_python/addon.py | 15 + .../extension/speko_llm2_python/client.py | 199 ++++++++ .../extension/speko_llm2_python/config.py | 55 +++ .../extension/speko_llm2_python/extension.py | 424 ++++++++++++++++++ .../extension/speko_llm2_python/manifest.json | 62 +++ .../extension/speko_llm2_python/property.json | 12 + .../speko_llm2_python/pyproject.toml | 8 + .../speko_llm2_python/requirements.txt | 2 + .../speko_llm2_python/tests/bin/start | 7 + .../speko_llm2_python/tests/conftest.py | 7 + .../speko_llm2_python/tests/test_client.py | 143 ++++++ .../extension/speko_llm2_python/uv.lock | 260 +++++++++++ .../extension/speko_tts2_python/README.md | 39 ++ .../extension/speko_tts2_python/__init__.py | 6 + .../extension/speko_tts2_python/addon.py | 15 + .../extension/speko_tts2_python/client.py | 276 ++++++++++++ .../extension/speko_tts2_python/config.py | 92 ++++ .../extension/speko_tts2_python/extension.py | 360 +++++++++++++++ .../extension/speko_tts2_python/manifest.json | 70 +++ .../extension/speko_tts2_python/property.json | 18 + .../speko_tts2_python/pyproject.toml | 8 + .../speko_tts2_python/requirements.txt | 2 + .../speko_tts2_python/tests/bin/start | 7 + .../property_basic_audio_setting1.json | 9 + .../property_basic_audio_setting2.json | 9 + .../tests/configs/property_dump.json | 9 + .../tests/configs/property_invalid.json | 5 + .../tests/configs/property_miss_required.json | 5 + .../speko_tts2_python/tests/conftest.py | 7 + .../speko_tts2_python/tests/test_client.py | 195 ++++++++ .../extension/speko_tts2_python/uv.lock | 238 ++++++++++ 51 files changed, 3927 insertions(+) create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/README.md create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/__init__.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/addon.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/client.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/config.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/property.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/requirements.txt create mode 100755 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_dump.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_en.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_invalid.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_zh.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/uv.lock create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/__init__.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/addon.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/manifest.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/property.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/requirements.txt create mode 100755 ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/uv.lock create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/__init__.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/addon.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/manifest.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/property.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/requirements.txt create mode 100755 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_basic_audio_setting1.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_basic_audio_setting2.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_dump.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_invalid.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_miss_required.json create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/uv.lock diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md new file mode 100644 index 0000000000..8df8aab132 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -0,0 +1,43 @@ +# Speko Router ASR extension + +Streams TEN PCM audio to the Speko Router and emits the standard TEN +`asr_result`, `asr_finalize_end`, `metrics`, `error`, and connection-status +messages. + +## Configuration + +Set `SPEKO_API_KEY`, then use the extension defaults or override `params` in +your graph. + +| Property | Default | Description | +| --- | --- | --- | +| `params.base_url` | `https://router.speko.dev` | Speko Router origin | +| `params.sample_rate` | `16000` | PCM input sample rate | +| `params.channels` | `1` | PCM input channel count | +| `params.language` | `en-US` | Optional routing/transcription hint | +| `params.routing` | auto, balanced | Speko routing selection | +| `params.options` | `{}` | Diarization, keywords, noise reduction, or namespaced provider options | +| `params.buffer_duration_ms` | `5000` | Audio retained while disconnected; `0` discards | + +The transport is raw signed 16-bit little-endian PCM. A TEN `asr_finalize` +message sends Speko `input.commit` and `asr_finalize_end` is emitted after the +corresponding final transcript. + +```json +{ + "type": "extension", + "name": "asr", + "addon": "speko_asr_python", + "extension_group": "asr", + "property": { + "params": { + "api_key": "${env:SPEKO_API_KEY}", + "routing": {"mode": "auto", "objective": "latency"}, + "language": "en-US" + } + } +} +``` + +For an explicit route, set `routing` to +`{"mode":"explicit","provider":"deepgram","model":"nova-3"}`. diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/__init__.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/__init__.py new file mode 100644 index 0000000000..00ba4e1293 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/__init__.py @@ -0,0 +1,6 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +from . import addon as addon diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/addon.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/addon.py new file mode 100644 index 0000000000..88584481b0 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/addon.py @@ -0,0 +1,15 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +from ten_runtime import Addon, TenEnv, register_addon_as_extension + + +@register_addon_as_extension("speko_asr_python") +class SpekoASRExtensionAddon(Addon): + def on_create_instance(self, ten_env: TenEnv, name: str, context) -> None: + from .extension import SpekoASRExtension + + ten_env.log_info("SpekoASRExtensionAddon on_create_instance") + ten_env.on_create_instance_done(SpekoASRExtension(name), context) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py new file mode 100644 index 0000000000..683c1a5d2a --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py @@ -0,0 +1,256 @@ +import asyncio +import json +import uuid +from collections.abc import Awaitable, Callable +from typing import Any +from urllib.parse import urlparse, urlunparse + +import websockets +from websockets.asyncio.client import ClientConnection +from websockets.exceptions import InvalidStatus + +EventHandler = Callable[[dict[str, Any]], Awaitable[None]] +DisconnectHandler = Callable[["SpekoRouterError | None"], Awaitable[None]] + + +class SpekoRouterError(Exception): + def __init__( + self, + code: str, + message: str, + *, + retryable: bool = False, + request_id: str = "", + hint: str = "", + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retryable = retryable + self.request_id = request_id + self.hint = hint + + @classmethod + def from_event(cls, event: dict[str, Any]) -> "SpekoRouterError": + error = event.get("error", {}) + return cls( + str(error.get("code", "relay_error")), + str(error.get("message", "Speko Router error")), + retryable=bool(error.get("retryable", False)), + request_id=str(error.get("request_id", "")), + hint=str(error.get("hint", "")), + ) + + +def websocket_url(base_url: str, path: str) -> str: + parsed = urlparse(base_url) + scheme = {"https": "wss", "http": "ws"}.get(parsed.scheme, parsed.scheme) + base_path = parsed.path.rstrip("/") + return urlunparse((scheme, parsed.netloc, f"{base_path}{path}", "", "", "")) + + +class SpekoASRClient: + def __init__( + self, + *, + api_key: str, + base_url: str, + configure: dict[str, Any], + ready_timeout_sec: float, + finalize_timeout_sec: float, + on_event: EventHandler, + on_disconnect: DisconnectHandler, + ) -> None: + self.api_key = api_key + self.url = websocket_url(base_url, "/v1/stt/stream") + self.configure = configure + self.ready_timeout_sec = ready_timeout_sec + self.finalize_timeout_sec = finalize_timeout_sec + self.on_event = on_event + self.on_disconnect = on_disconnect + + self.request_id = "" + self.route: dict[str, Any] = {} + self.usage: dict[str, Any] = {} + self._ws: ClientConnection | None = None + self._listener_task: asyncio.Task[None] | None = None + self._finalize_waiter: asyncio.Future[None] | None = None + self._ready = False + self._closing = False + + @property + def is_ready(self) -> bool: + return self._ready and self._ws is not None + + async def connect(self) -> None: + if self.is_ready: + return + + self._closing = False + headers = { + "Authorization": f"Bearer {self.api_key}", + "Idempotency-Key": uuid.uuid4().hex, + } + try: + self._ws = await websockets.connect( + self.url, + additional_headers=headers, + open_timeout=self.ready_timeout_sec, + ) + configure_frame = json.dumps(self.configure, separators=(",", ":")) + await self._ws.send(configure_frame) + raw = await asyncio.wait_for( + self._ws.recv(), timeout=self.ready_timeout_sec + ) + event = self._decode_text_event(raw) + if event.get("type") == "error": + raise SpekoRouterError.from_event(event) + if event.get("type") != "session.ready": + raise SpekoRouterError( + "relay_error", + "Expected session.ready from Speko Router", + retryable=True, + ) + self.request_id = str(event.get("request_id", "")) + self.route = dict(event.get("route", {})) + self._ready = True + await self.on_event(event) + self._listener_task = asyncio.create_task(self._listen()) + except InvalidStatus as error: + status = error.response.status_code + code = ( + "authentication_failed" + if status in (401, 403) + else "relay_error" + ) + raise SpekoRouterError( + code, + f"Speko Router WebSocket upgrade failed ({status})", + retryable=status >= 500 or status == 429, + ) from error + except Exception: + await self._close_socket() + raise + + async def send_audio(self, audio: bytes) -> None: + if not self.is_ready or self._ws is None: + raise SpekoRouterError( + "relay_error", "Speko ASR session is not ready", retryable=True + ) + await self._ws.send(audio) + + async def commit(self) -> None: + if not self.is_ready or self._ws is None: + raise SpekoRouterError( + "relay_error", "Speko ASR session is not ready", retryable=True + ) + if self._finalize_waiter and not self._finalize_waiter.done(): + await self._finalize_waiter + return + + self._finalize_waiter = asyncio.get_running_loop().create_future() + await self._ws.send('{"type":"input.commit"}') + try: + await asyncio.wait_for( + asyncio.shield(self._finalize_waiter), + timeout=self.finalize_timeout_sec, + ) + except asyncio.TimeoutError as error: + raise SpekoRouterError( + "request_timeout", + "Timed out waiting for a final Speko transcript", + retryable=True, + ) from error + + async def close(self) -> None: + self._closing = True + if self._ws is not None and self._ready: + try: + await self._ws.send('{"type":"session.close"}') + except Exception: + pass + await self._close_socket() + + async def _listen(self) -> None: + error: SpekoRouterError | None = None + try: + assert self._ws is not None + async for raw in self._ws: + if isinstance(raw, bytes): + raise SpekoRouterError( + "relay_error", + "Unexpected binary frame on Speko ASR session", + retryable=True, + ) + event = self._decode_text_event(raw) + event_type = event.get("type") + if event_type == "error": + error = SpekoRouterError.from_event(event) + break + if event_type == "usage.updated": + self.usage = dict(event.get("usage", {})) + elif event_type == "session.closed": + self.usage = dict(event.get("usage", {})) + break + + await self.on_event(event) + if event_type == "transcript.final": + waiter = self._finalize_waiter + if waiter is not None and not waiter.done(): + waiter.set_result(None) + except SpekoRouterError as caught: + error = caught + except Exception as caught: # pragma: no cover - transport-specific + error = SpekoRouterError("relay_error", str(caught), retryable=True) + finally: + self._ready = False + waiter = self._finalize_waiter + if waiter is not None and not waiter.done(): + waiter.set_exception( + error + or SpekoRouterError( + "relay_error", + "Speko ASR session closed before final transcript", + retryable=True, + ) + ) + if not self._closing or error is not None: + await self.on_disconnect(error) + + async def _close_socket(self) -> None: + self._ready = False + task = self._listener_task + self._listener_task = None + if task is not None and task is not asyncio.current_task(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if self._ws is not None: + try: + await self._ws.close() + except Exception: + pass + self._ws = None + + @staticmethod + def _decode_text_event(raw: str | bytes) -> dict[str, Any]: + if not isinstance(raw, str): + raise SpekoRouterError( + "relay_error", + "Expected a JSON text frame from Speko Router", + retryable=True, + ) + try: + event = json.loads(raw) + except json.JSONDecodeError as error: + raise SpekoRouterError( + "relay_error", + "Speko Router returned invalid JSON", + retryable=True, + ) from error + if not isinstance(event, dict): + raise SpekoRouterError( + "relay_error", + "Speko Router event must be a JSON object", + retryable=True, + ) + return event diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py new file mode 100644 index 0000000000..9d267c8756 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py @@ -0,0 +1,97 @@ +import copy +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from ten_ai_base import utils + + +class SpekoASRConfig(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + dump: bool = False + dump_path: str = str(Path("/tmp") / "speko_asr_in.pcm") + params: dict[str, Any] = Field(default_factory=dict) + + api_key: str = "" + base_url: str = "https://router.speko.dev" + sample_rate: int = 16000 + channels: int = 1 + language: str = "en-US" + routing: dict[str, Any] = Field( + default_factory=lambda: { + "mode": "auto", + "objective": "balanced", + } + ) + options: dict[str, Any] = Field(default_factory=dict) + ready_timeout_sec: float = 10.0 + finalize_timeout_sec: float = 10.0 + buffer_duration_ms: int = 5000 + + @field_validator("sample_rate") + @classmethod + def validate_sample_rate(cls, value: int) -> int: + if not 8000 <= value <= 192000: + raise ValueError("sample_rate must be between 8000 and 192000") + return value + + @field_validator("channels") + @classmethod + def validate_channels(cls, value: int) -> int: + if not 1 <= value <= 8: + raise ValueError("channels must be between 1 and 8") + return value + + def update_params(self) -> None: + values = dict(self.params) + for name in ( + "api_key", + "base_url", + "sample_rate", + "channels", + "language", + "routing", + "options", + "ready_timeout_sec", + "finalize_timeout_sec", + "buffer_duration_ms", + ): + if name in values: + setattr(self, name, values.pop(name)) + self.params = values + self._validate_required() + + def _validate_required(self) -> None: + if not self.api_key.strip(): + raise ValueError("api_key is required") + if not self.base_url.strip(): + raise ValueError("base_url is required") + if self.buffer_duration_ms < 0: + raise ValueError("buffer_duration_ms must not be negative") + self._validate_routing() + + def _validate_routing(self) -> None: + # pylint: disable=no-member + mode = self.routing.get("mode", "auto") + if mode == "auto": + objective = self.routing.get("objective", "balanced") + if objective not in {"balanced", "quality", "latency", "cost"}: + raise ValueError("unsupported routing objective") + return + if mode == "explicit": + model = str(self.routing.get("model", "")) + provider = str(self.routing.get("provider", "")) + if not model or (not provider and "/" not in model): + raise ValueError( + "explicit routing requires provider and model, " + "or a provider/model value" + ) + return + raise ValueError("routing mode must be auto or explicit") + + def to_str(self, sensitive_handling: bool = True) -> str: + config = copy.deepcopy(self) + if sensitive_handling and config.api_key: + config.api_key = utils.encrypt(config.api_key) + return f"{config}" diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py new file mode 100644 index 0000000000..2f0d70dff7 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -0,0 +1,359 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +import time +import traceback +from pathlib import Path +from typing import Any + +from ten_ai_base.asr import ( + ASRBufferConfig, + ASRBufferConfigModeDiscard, + ASRBufferConfigModeKeep, + ASRResult, + AsyncASRBaseExtension, +) +from ten_ai_base.const import LOG_CATEGORY_KEY_POINT, LOG_CATEGORY_VENDOR +from ten_ai_base.dumper import Dumper +from ten_ai_base.message import ( + ModuleError, + ModuleErrorCode, + ModuleErrorVendorInfo, +) +from ten_ai_base.struct import ASRWord +from ten_runtime import AsyncTenEnv, AudioFrame +from typing_extensions import override + +from .client import SpekoASRClient, SpekoRouterError +from .config import SpekoASRConfig + + +class SpekoASRExtension(AsyncASRBaseExtension): + def __init__(self, name: str) -> None: + super().__init__(name) + self.config: SpekoASRConfig | None = None + self.client: SpekoASRClient | None = None + self.audio_dumper: Dumper | None = None + self._connect_started_at = 0.0 + self._total_audio_bytes = 0 + self._final_cursor_ms = 0 + self._route: dict[str, Any] = {} + + @override + def vendor(self) -> str: + return "speko" + + @override + def vendor_metadata(self) -> dict[str, Any]: + if self.config is None: + return {} + metadata: dict[str, Any] = { + "base_url": self.config.base_url, + "routing": self.config.routing, + } + if self._route: + metadata["route"] = self._route + metadata["model"] = self._route.get("model", "") + return metadata + + @override + async def on_init(self, ten_env: AsyncTenEnv) -> None: + await super().on_init(ten_env) + try: + config_json, error = await ten_env.get_property_to_json("") + if error: + raise RuntimeError(f"Failed to read configuration: {error}") + self.config = SpekoASRConfig.model_validate_json(config_json) + self.config.update_params() + ten_env.log_info( + f"config: {self.config.to_str(sensitive_handling=True)}", + category=LOG_CATEGORY_KEY_POINT, + ) + await self._start_dumper() + except Exception as error: + self.config = None + ten_env.log_error( + f"invalid property: {error}", + category=LOG_CATEGORY_KEY_POINT, + ) + await self.send_asr_error( + ModuleError( + module="asr", + code=ModuleErrorCode.FATAL_ERROR.value, + message=str(error), + vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + ) + ) + + @override + async def start_connection(self) -> None: + if self.config is None: + await self.on_disconnected( + code=ModuleErrorCode.FATAL_ERROR.value, + message="Speko ASR is not configured", + vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + ) + return + if self.client is not None: + await self.client.close() + + self._connect_started_at = time.monotonic() + self.client = SpekoASRClient( + api_key=self.config.api_key, + base_url=self.config.base_url, + configure=self._configure_frame(), + ready_timeout_sec=self.config.ready_timeout_sec, + finalize_timeout_sec=self.config.finalize_timeout_sec, + on_event=self._on_router_event, + on_disconnect=self._on_router_disconnect, + ) + try: + await self.client.connect() + except SpekoRouterError as error: + await self._report_router_error(error) + await self.on_disconnected( + code=self._module_error_code(error).value, + message=error.message, + vendor_info=self._vendor_info(error), + ) + except Exception as error: + self.ten_env.log_error( + f"vendor_error: {traceback.format_exc()}", + category=LOG_CATEGORY_VENDOR, + ) + router_error = SpekoRouterError( + "relay_error", str(error), retryable=True + ) + await self._report_router_error(router_error) + await self.on_disconnected( + code=ModuleErrorCode.NON_FATAL_ERROR.value, + message=str(error), + vendor_info=self._vendor_info(router_error), + ) + + @override + def is_connected(self) -> bool: + return self.client is not None and self.client.is_ready + + @override + async def stop_connection(self) -> None: + if self.client is not None: + await self.client.close() + self.client = None + if self.audio_dumper is not None: + await self.audio_dumper.stop() + self.audio_dumper = None + + @override + def input_audio_sample_rate(self) -> int: + return self.config.sample_rate if self.config else 16000 + + @override + def input_audio_channels(self) -> int: + return self.config.channels if self.config else 1 + + @override + def buffer_strategy(self) -> ASRBufferConfig: + if self.config is None or self.config.buffer_duration_ms == 0: + return ASRBufferConfigModeDiscard() + bytes_per_ms = ( + self.input_audio_sample_rate() + * self.input_audio_channels() + * self.input_audio_sample_width() + // 1000 + ) + return ASRBufferConfigModeKeep( + byte_limit=bytes_per_ms * self.config.buffer_duration_ms + ) + + @override + async def send_audio( + self, frame: AudioFrame, session_id: str | None + ) -> bool: + del session_id + if not self.is_connected() or self.client is None: + return False + buffer = frame.lock_buf() + try: + audio = bytes(buffer) + await self.client.send_audio(audio) + self._total_audio_bytes += len(audio) + self.audio_timeline.add_user_audio( + self._audio_duration_ms(len(audio)) + ) + if self.audio_dumper is not None: + await self.audio_dumper.push_bytes(audio) + return True + except SpekoRouterError as error: + await self._report_router_error(error) + return False + except Exception as error: + self.ten_env.log_error(f"failed to send audio: {error}") + return False + finally: + frame.unlock_buf(buffer) + + @override + async def finalize(self, session_id: str | None) -> None: + del session_id + if not self.is_connected() or self.client is None: + await self.send_asr_error( + ModuleError( + module="asr", + code=ModuleErrorCode.NON_FATAL_ERROR.value, + message="Speko ASR session is not connected", + vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + ) + ) + await self.send_asr_finalize_end() + return + try: + await self.client.commit() + await self.send_asr_finalize_end() + except SpekoRouterError as error: + if self.is_connected(): + await self._report_router_error(error) + await self.send_asr_finalize_end() + + async def _on_router_event(self, event: dict[str, Any]) -> None: + event_type = event.get("type") + if event_type == "session.ready": + self._route = dict(event.get("route", {})) + connect_delay = int( + (time.monotonic() - self._connect_started_at) * 1000 + ) + await self.on_connected() + await self.send_connect_delay_metrics(connect_delay) + self.ten_env.log_info( + f"vendor_status_changed: Speko ASR ready; route={self._route}", + category=LOG_CATEGORY_VENDOR, + ) + elif event_type == "transcript.delta": + await self._send_transcript(event, final=False) + elif event_type == "transcript.final": + await self._send_transcript(event, final=True) + elif event_type == "usage.updated": + usage = dict(event.get("usage", {})) + await self.send_vendor_metrics({"usage": usage}) + + async def _on_router_disconnect( + self, error: SpekoRouterError | None + ) -> None: + if error is not None: + await self._report_router_error(error) + await self.on_disconnected( + code=( + self._module_error_code(error).value if error is not None else 0 + ), + message=error.message if error is not None else "closed", + vendor_info=( + self._vendor_info(error) if error is not None else None + ), + ) + + async def _send_transcript( + self, event: dict[str, Any], *, final: bool + ) -> None: + segments = event.get("segments") or [] + words: list[ASRWord] = [] + if segments: + start_ms = min(int(segment["start_ms"]) for segment in segments) + end_ms = max(int(segment["end_ms"]) for segment in segments) + for segment in segments: + segment_start = int(segment["start_ms"]) + segment_end = int(segment["end_ms"]) + words.append( + ASRWord( + word=str(segment.get("text", "")), + start_ms=segment_start, + duration_ms=max(0, segment_end - segment_start), + stable=final, + ) + ) + else: + start_ms = self._final_cursor_ms + end_ms = self._audio_duration_ms(self._total_audio_bytes) + + metadata: dict[str, Any] = { + "asr_info": {"vendor": self.vendor(), "locked": False} + } + if event.get("speaker") is not None: + metadata["speaker"] = event["speaker"] + + await self.send_asr_result( + ASRResult( + text=str(event.get("text", "")), + final=final, + start_ms=start_ms, + duration_ms=max(0, end_ms - start_ms), + language=self.config.language if self.config else "en-US", + words=words, + metadata=metadata, + ) + ) + if final: + self._final_cursor_ms = max(self._final_cursor_ms, end_ms) + + async def _report_router_error(self, error: SpekoRouterError) -> None: + self.ten_env.log_error( + f"vendor_error: code={error.code}, message={error.message}", + category=LOG_CATEGORY_VENDOR, + ) + await self.send_asr_error( + ModuleError( + module="asr", + code=self._module_error_code(error).value, + message=error.message, + vendor_info=self._vendor_info(error), + ) + ) + + def _configure_frame(self) -> dict[str, Any]: + assert self.config is not None + frame: dict[str, Any] = { + "type": "session.configure", + "audio": { + "encoding": "pcm_s16le", + "sample_rate_hz": self.config.sample_rate, + "channels": self.config.channels, + }, + } + if self.config.routing: + frame["routing"] = self.config.routing + if self.config.language: + frame["language"] = self.config.language + if self.config.options: + frame["options"] = self.config.options + return frame + + async def _start_dumper(self) -> None: + if self.config is None or not self.config.dump: + return + dump_path = Path(self.config.dump_path) + if dump_path.suffix != ".pcm": + dump_path = dump_path / "speko_asr_in.pcm" + dump_path.parent.mkdir(parents=True, exist_ok=True) + self.audio_dumper = Dumper(str(dump_path)) + await self.audio_dumper.start() + + def _audio_duration_ms(self, byte_count: int) -> int: + bytes_per_second = ( + self.input_audio_sample_rate() + * self.input_audio_channels() + * self.input_audio_sample_width() + ) + return int(byte_count * 1000 / bytes_per_second) + + @staticmethod + def _module_error_code(error: SpekoRouterError) -> ModuleErrorCode: + fatal_codes = {"authentication_failed", "insufficient_credit"} + if error.code in fatal_codes: + return ModuleErrorCode.FATAL_ERROR + return ModuleErrorCode.NON_FATAL_ERROR + + def _vendor_info(self, error: SpekoRouterError) -> ModuleErrorVendorInfo: + return ModuleErrorVendorInfo( + vendor=self.vendor(), code=error.code, message=error.message + ) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json b/ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json new file mode 100644 index 0000000000..19b267e41c --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json @@ -0,0 +1,72 @@ +{ + "type": "extension", + "name": "speko_asr_python", + "version": "0.1.0", + "display_name": { + "locales": { + "en-US": { + "content": "Speko Router ASR Python Extension" + } + } + }, + "description": { + "locales": { + "en-US": { + "content": "Streaming speech recognition routed through Speko" + } + } + }, + "tags": ["python", "speko", "asr", "router"], + "dependencies": [ + { + "type": "system", + "name": "ten_runtime_python", + "version": "0.11" + }, + { + "type": "system", + "name": "ten_ai_base", + "version": "0.7" + } + ], + "api": { + "interface": [ + { + "import_uri": "../../system/ten_ai_base/api/asr-interface.json" + } + ], + "property": { + "properties": { + "auto_connect": {"type": "bool"}, + "dump": {"type": "bool"}, + "dump_path": {"type": "string"}, + "params": { + "type": "object", + "properties": { + "api_key": {"type": "string"}, + "base_url": {"type": "string"}, + "sample_rate": {"type": "int32"}, + "channels": {"type": "int32"}, + "language": {"type": "string"}, + "routing": {"type": "object", "properties": {}}, + "options": {"type": "object", "properties": {}}, + "ready_timeout_sec": {"type": "float64"}, + "finalize_timeout_sec": {"type": "float64"}, + "buffer_duration_ms": {"type": "int32"} + } + } + } + } + }, + "scripts": {"test": "tests/bin/start"}, + "package": { + "include": [ + "manifest.json", + "property.json", + "**.py", + "README.md", + "pyproject.toml", + "requirements.txt" + ] + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/property.json b/ai_agents/agents/ten_packages/extension/speko_asr_python/property.json new file mode 100644 index 0000000000..0a0d7c7ed6 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/property.json @@ -0,0 +1,20 @@ +{ + "auto_connect": true, + "dump": false, + "dump_path": "/tmp/speko_asr_in.pcm", + "params": { + "api_key": "${env:SPEKO_API_KEY|}", + "base_url": "https://router.speko.dev", + "sample_rate": 16000, + "channels": 1, + "language": "en-US", + "routing": { + "mode": "auto", + "objective": "balanced" + }, + "options": {}, + "ready_timeout_sec": 10.0, + "finalize_timeout_sec": 10.0, + "buffer_duration_ms": 5000 + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml new file mode 100644 index 0000000000..972d45cf2b --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "speko-asr-python" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2.13.4", + "typing-extensions>=4.15.0", + "websockets>=14.0,<16", +] diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_asr_python/requirements.txt new file mode 100644 index 0000000000..9206f4801b --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/requirements.txt @@ -0,0 +1,3 @@ +pydantic>=2.13.4 +typing_extensions>=4.15.0 +websockets>=14.0,<16 diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start new file mode 100755 index 0000000000..0a273c6ea9 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +export PYTHONPATH=".:${PYTHONPATH}" +pytest -q tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_dump.json b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_dump.json new file mode 100644 index 0000000000..60c06b836e --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_dump.json @@ -0,0 +1,8 @@ +{ + "dump": true, + "dump_path": "/tmp/speko_asr_guarder.pcm", + "params": { + "api_key": "${env:SPEKO_API_KEY|}", + "language": "en-US" + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_en.json b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_en.json new file mode 100644 index 0000000000..b35b671107 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_en.json @@ -0,0 +1,8 @@ +{ + "params": { + "api_key": "${env:SPEKO_API_KEY|}", + "language": "en-US", + "sample_rate": 16000, + "channels": 1 + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_invalid.json b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_invalid.json new file mode 100644 index 0000000000..f4457d5326 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_invalid.json @@ -0,0 +1,6 @@ +{ + "params": { + "api_key": "invalid", + "language": "en-US" + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_zh.json b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_zh.json new file mode 100644 index 0000000000..cb35da1efc --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/configs/property_zh.json @@ -0,0 +1,8 @@ +{ + "params": { + "api_key": "${env:SPEKO_API_KEY|}", + "language": "zh-CN", + "sample_rate": 16000, + "channels": 1 + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py new file mode 100644 index 0000000000..93300358db --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path +from types import ModuleType + +package_name = Path(__file__).parents[1].name +addon_name = f"{package_name}.addon" +sys.modules.setdefault(addon_name, ModuleType(addon_name)) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py new file mode 100644 index 0000000000..f472eab98d --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py @@ -0,0 +1,153 @@ +import asyncio +import json +from unittest.mock import AsyncMock, patch + +import pytest +from client import SpekoASRClient, SpekoRouterError, websocket_url + + +class FakeWebSocket: + def __init__(self, *messages): + self.messages = asyncio.Queue() + for message in messages: + self.messages.put_nowait(message) + self.sent = [] + self.closed = False + + async def send(self, value): + self.sent.append(value) + + async def recv(self): + return await self.messages.get() + + def __aiter__(self): + return self + + async def __anext__(self): + if self.closed: + raise StopAsyncIteration + return await self.recv() + + async def close(self): + self.closed = True + + +def make_client(events, disconnects): + return SpekoASRClient( + api_key="test-key", + base_url="https://router.speko.dev", + configure={ + "type": "session.configure", + "audio": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + }, + }, + ready_timeout_sec=1, + finalize_timeout_sec=1, + on_event=AsyncMock(side_effect=lambda event: events.append(event)), + on_disconnect=AsyncMock( + side_effect=lambda error: disconnects.append(error) + ), + ) + + +@pytest.mark.asyncio +async def test_streams_audio_and_waits_for_final_transcript(): + ready = json.dumps( + { + "type": "session.ready", + "request_id": "req_1", + "route": { + "provider": "deepgram", + "model": "nova-3", + "region": "us", + "attempt_id": "att_1", + }, + } + ) + websocket = FakeWebSocket(ready) + events = [] + disconnects = [] + client = make_client(events, disconnects) + + with patch( + "client.websockets.connect", AsyncMock(return_value=websocket) + ) as connect: + await client.connect() + await client.send_audio(b"\x00\x01") + commit_task = asyncio.create_task(client.commit()) + await asyncio.sleep(0) + await websocket.messages.put( + json.dumps({"type": "transcript.delta", "text": "hel"}) + ) + await websocket.messages.put( + json.dumps({"type": "transcript.final", "text": "hello"}) + ) + await commit_task + + assert connect.await_args.args[0] == "wss://router.speko.dev/v1/stt/stream" + headers = connect.await_args.kwargs["additional_headers"] + assert headers["Authorization"] == "Bearer test-key" + assert headers["Idempotency-Key"] + assert json.loads(websocket.sent[0])["type"] == "session.configure" + assert websocket.sent[1] == b"\x00\x01" + assert json.loads(websocket.sent[2]) == {"type": "input.commit"} + assert [event["type"] for event in events] == [ + "session.ready", + "transcript.delta", + "transcript.final", + ] + assert disconnects == [] + await client.close() + + +@pytest.mark.asyncio +async def test_terminal_error_is_normalized(): + ready = json.dumps( + { + "type": "session.ready", + "request_id": "req_1", + "route": { + "provider": "x", + "model": "y", + "region": "us", + "attempt_id": "att_1", + }, + } + ) + websocket = FakeWebSocket(ready) + events = [] + disconnects = [] + client = make_client(events, disconnects) + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + await websocket.messages.put( + json.dumps( + { + "type": "error", + "error": { + "code": "provider_unavailable", + "message": "try later", + "hint": "retry", + "retryable": True, + }, + } + ) + ) + for _ in range(20): + if disconnects: + break + await asyncio.sleep(0) + + assert isinstance(disconnects[0], SpekoRouterError) + assert disconnects[0].code == "provider_unavailable" + assert disconnects[0].retryable is True + assert client.is_ready is False + + +def test_websocket_url_preserves_base_path(): + assert websocket_url("http://localhost:8080/router/", "/v1/stt/stream") == ( + "ws://localhost:8080/router/v1/stt/stream" + ) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/uv.lock b/ai_agents/agents/ten_packages/extension/speko_asr_python/uv.lock new file mode 100644 index 0000000000..863ffff93b --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/uv.lock @@ -0,0 +1,240 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "speko-asr-python" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, + { name = "websockets", specifier = ">=14.0,<16" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md b/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md new file mode 100644 index 0000000000..4ed23585b3 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md @@ -0,0 +1,40 @@ +# Speko Router LLM2 extension + +Maps the TEN LLM2 command interface to Speko's Responses API. Streaming text, +function calls, full conversation history, usage, aborts, and Router errors +are preserved in TEN-native events. + +## Configuration + +Set `SPEKO_API_KEY` and add the extension to a graph. + +| Property | Default | Description | +| --- | --- | --- | +| `base_url` | `https://router.speko.dev` | Speko Router origin | +| `prompt` | helpful assistant | Default system prompt | +| `max_output_tokens` | `512` | Required reservation ceiling | +| `temperature` | `0.7` | Sampling temperature | +| `top_p` | unset | Optional nucleus sampling value | +| `routing` | auto, balanced | Speko routing selection | + +```json +{ + "type": "extension", + "name": "llm", + "addon": "speko_llm2_python", + "extension_group": "llm", + "property": { + "api_key": "${env:SPEKO_API_KEY}", + "routing": {"mode": "auto", "objective": "balanced"}, + "max_output_tokens": 512 + } +} +``` + +TEN request parameters may override `routing`, `max_output_tokens` (or +`max_tokens`), `temperature`, `top_p`, and `response_format`. A TEN `model` +override must use `provider/model`, unless `routing.provider` is already +configured in explicit mode. + +Speko's launch Responses contract accepts text content only. Image content is +rejected locally with a clear error instead of being silently dropped. diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/__init__.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/__init__.py new file mode 100644 index 0000000000..00ba4e1293 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/__init__.py @@ -0,0 +1,6 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +from . import addon as addon diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/addon.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/addon.py new file mode 100644 index 0000000000..d86d927de2 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/addon.py @@ -0,0 +1,15 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +from ten_runtime import Addon, TenEnv, register_addon_as_extension + + +@register_addon_as_extension("speko_llm2_python") +class SpekoLLM2ExtensionAddon(Addon): + def on_create_instance(self, ten_env: TenEnv, name: str, context) -> None: + from .extension import SpekoLLM2Extension + + ten_env.log_info("SpekoLLM2ExtensionAddon on_create_instance") + ten_env.on_create_instance_done(SpekoLLM2Extension(name), context) diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py new file mode 100644 index 0000000000..3015f161a5 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py @@ -0,0 +1,199 @@ +import json +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any + +import httpx + + +class SpekoRouterError(Exception): + def __init__( + self, + code: str, + message: str, + *, + retryable: bool = False, + request_id: str = "", + status_code: int = 0, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retryable = retryable + self.request_id = request_id + self.status_code = status_code + + @classmethod + def from_envelope( + cls, envelope: dict[str, Any], *, status_code: int = 0 + ) -> "SpekoRouterError": + error = envelope.get("error", {}) + return cls( + str(error.get("code", "relay_error")), + str(error.get("message", "Speko Router error")), + retryable=bool(error.get("retryable", False)), + request_id=str(error.get("request_id", "")), + status_code=status_code, + ) + + +@dataclass(frozen=True) +class SSEEvent: + name: str + data: dict[str, Any] + + +async def iter_sse(lines: AsyncIterator[str]) -> AsyncIterator[SSEEvent]: + event_name = "message" + data_lines: list[str] = [] + async for line in lines: + if line == "": + if data_lines: + yield SSEEvent( + event_name, + _decode_json("\n".join(data_lines)), + ) + event_name = "message" + data_lines = [] + continue + if line.startswith(":"): + continue + field, separator, value = line.partition(":") + if separator and value.startswith(" "): + value = value[1:] + if field == "event": + event_name = value + elif field == "data": + data_lines.append(value) + + if data_lines: + yield SSEEvent(event_name, _decode_json("\n".join(data_lines))) + + +def _decode_json(value: str) -> dict[str, Any]: + try: + decoded = json.loads(value) + except json.JSONDecodeError as error: + raise SpekoRouterError( + "relay_error", "Speko Router returned invalid SSE JSON" + ) from error + if not isinstance(decoded, dict): + raise SpekoRouterError( + "relay_error", "Speko Router SSE data must be a JSON object" + ) + return decoded + + +class SpekoLLMClient: + def __init__( + self, + *, + api_key: str, + base_url: str, + timeout_sec: float, + http_client: httpx.AsyncClient | None = None, + ) -> None: + self.url = f"{base_url.rstrip('/')}/v1/llm/responses" + self._owns_client = http_client is None + self._client = http_client or httpx.AsyncClient(timeout=timeout_sec) + self._headers = { + "Authorization": f"Bearer {api_key}", + "Accept": "text/event-stream", + } + self.route: dict[str, Any] = {} + + async def stream( + self, payload: dict[str, Any], *, idempotency_key: str + ) -> AsyncIterator[SSEEvent]: + headers = {**self._headers, "Idempotency-Key": idempotency_key} + terminal = False + try: + async with self._client.stream( + "POST", self.url, headers=headers, json=payload + ) as response: + if response.status_code >= 400: + raise await self._error_from_response(response) + self.route = { + "provider": response.headers.get("Speko-Provider", ""), + "model": response.headers.get("Speko-Model", ""), + "region": response.headers.get("Speko-Region", ""), + "attempt_id": response.headers.get("Speko-Attempt-ID", ""), + } + async for event in iter_sse(response.aiter_lines()): + if event.name == "error": + raise SpekoRouterError.from_envelope(event.data) + yield event + if event.name == "response.completed": + terminal = True + break + except httpx.TimeoutException as error: + raise SpekoRouterError( + "request_timeout", + "Timed out waiting for Speko Router", + retryable=True, + ) from error + except httpx.HTTPError as error: + raise SpekoRouterError( + "relay_error", str(error), retryable=True + ) from error + if not terminal: + raise SpekoRouterError( + "relay_error", + "Speko Router LLM stream ended without a terminal event", + retryable=True, + ) + + async def complete( + self, payload: dict[str, Any], *, idempotency_key: str + ) -> dict[str, Any]: + headers = { + **self._headers, + "Accept": "application/json", + "Idempotency-Key": idempotency_key, + } + try: + response = await self._client.post( + self.url, headers=headers, json=payload + ) + except httpx.TimeoutException as error: + raise SpekoRouterError( + "request_timeout", + "Timed out waiting for Speko Router", + retryable=True, + ) from error + except httpx.HTTPError as error: + raise SpekoRouterError( + "relay_error", str(error), retryable=True + ) from error + if response.status_code >= 400: + raise await self._error_from_response(response) + decoded = response.json() + if not isinstance(decoded, dict): + raise SpekoRouterError( + "relay_error", "Speko Router response must be a JSON object" + ) + return decoded + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + @staticmethod + async def _error_from_response( + response: httpx.Response, + ) -> SpekoRouterError: + try: + await response.aread() + envelope = response.json() + except (json.JSONDecodeError, ValueError, httpx.ResponseNotRead): + return SpekoRouterError( + "relay_error", + f"Speko Router HTTP error ({response.status_code})", + retryable=response.status_code >= 500, + status_code=response.status_code, + ) + if not isinstance(envelope, dict): + envelope = {} + return SpekoRouterError.from_envelope( + envelope, status_code=response.status_code + ) diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py new file mode 100644 index 0000000000..298598460a --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py @@ -0,0 +1,55 @@ +import copy +from typing import Any + +from pydantic import BaseModel, Field +from ten_ai_base import utils + + +class SpekoLLM2Config(BaseModel): + api_key: str = "" + base_url: str = "https://router.speko.dev" + prompt: str = "You are a helpful assistant." + max_output_tokens: int = Field(default=512, ge=1) + temperature: float | None = Field(default=0.7, ge=0, le=2) + top_p: float | None = Field(default=None, gt=0, le=1) + timeout_sec: float = Field(default=60.0, gt=0) + routing: dict[str, Any] = Field( + default_factory=lambda: { + "mode": "auto", + "objective": "balanced", + } + ) + + def validate_required(self) -> None: + if not self.api_key.strip(): + raise ValueError("api_key is required") + if not self.base_url.strip(): + raise ValueError("base_url is required") + if self.max_output_tokens < 1: + raise ValueError("max_output_tokens must be positive") + self._validate_routing() + + def _validate_routing(self) -> None: + # pylint: disable=no-member + mode = self.routing.get("mode", "auto") + if mode == "auto": + objective = self.routing.get("objective", "balanced") + if objective not in {"balanced", "quality", "latency", "cost"}: + raise ValueError("unsupported routing objective") + return + if mode == "explicit": + model = str(self.routing.get("model", "")) + provider = str(self.routing.get("provider", "")) + if not model or (not provider and "/" not in model): + raise ValueError( + "explicit routing requires provider and model, " + "or a provider/model value" + ) + return + raise ValueError("routing mode must be auto or explicit") + + def to_str(self, sensitive_handling: bool = True) -> str: + config = copy.deepcopy(self) + if sensitive_handling and config.api_key: + config.api_key = utils.encrypt(config.api_key) + return f"{config}" diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py new file mode 100644 index 0000000000..7e7e5595a8 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py @@ -0,0 +1,424 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +import json +import time +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +from ten_ai_base.const import LOG_CATEGORY_KEY_POINT, LOG_CATEGORY_VENDOR +from ten_ai_base.llm2 import AsyncLLM2BaseExtension +from ten_ai_base.message import ( + ModuleError, + ModuleErrorCode, + ModuleErrorVendorInfo, + ModuleMetricKey, + ModuleMetrics, + ModuleType, +) +from ten_ai_base.struct import ( + ImageContent, + LLMMessageContent, + LLMMessageFunctionCall, + LLMMessageFunctionCallOutput, + LLMRequest, + LLMRequestRetrievePrompt, + LLMResponse, + LLMResponseMessageDelta, + LLMResponseMessageDone, + LLMResponseRetrievePrompt, + LLMResponseToolCall, + TextContent, +) +from ten_ai_base.types import LLMToolMetadata +from ten_runtime import AsyncTenEnv, Data + +from .client import SpekoLLMClient, SpekoRouterError +from .config import SpekoLLM2Config + + +class SpekoLLM2Extension(AsyncLLM2BaseExtension): + def __init__(self, name: str) -> None: + super().__init__(name) + self.config: SpekoLLM2Config | None = None + self.client: SpekoLLMClient | None = None + + async def on_start(self, ten_env: AsyncTenEnv) -> None: + await super().on_start(ten_env) + try: + config_json, error = await ten_env.get_property_to_json("") + if error: + raise RuntimeError(f"Failed to read configuration: {error}") + self.config = SpekoLLM2Config.model_validate_json(config_json) + self.config.validate_required() + ten_env.log_info( + f"config: {self.config.to_str(sensitive_handling=True)}", + category=LOG_CATEGORY_KEY_POINT, + ) + self.client = SpekoLLMClient( + api_key=self.config.api_key, + base_url=self.config.base_url, + timeout_sec=self.config.timeout_sec, + ) + except Exception as error: + self.config = None + ten_env.log_error( + f"invalid property: {error}", + category=LOG_CATEGORY_KEY_POINT, + ) + await self._send_error( + ModuleError( + module=ModuleType.LLM, + code=ModuleErrorCode.FATAL_ERROR.value, + message=str(error), + vendor_info=ModuleErrorVendorInfo(vendor="speko"), + ) + ) + + async def on_stop(self, ten_env: AsyncTenEnv) -> None: + await super().on_stop(ten_env) + if self.client is not None: + await self.client.close() + self.client = None + + async def on_retrieve_prompt( + self, ten_env: AsyncTenEnv, request: LLMRequestRetrievePrompt + ) -> LLMResponseRetrievePrompt: + del ten_env, request + return LLMResponseRetrievePrompt( + prompt=self.config.prompt if self.config else "" + ) + + async def on_call_chat_completion( + self, ten_env: AsyncTenEnv, request: LLMRequest + ) -> AsyncGenerator[LLMResponse, None]: + if self.config is None or self.client is None: + raise RuntimeError("Speko LLM extension is not configured") + + started_at = time.monotonic() + response_id = request.request_id + full_content = "" + ttft_ms: int | None = None + payload = self._build_payload(request) + stream = request.streaming is not False + payload["stream"] = stream + idempotency_key = uuid.uuid4().hex + + ten_env.log_info( + "Requesting Speko LLM response: " + f"messages={len(request.messages)}, stream={stream}, " + f"routing={payload.get('routing', {})}", + category=LOG_CATEGORY_VENDOR, + ) + + try: + if not stream: + response = await self.client.complete( + payload, idempotency_key=idempotency_key + ) + response_id = str(response.get("id", response_id)) + for item in response.get("output", []): + outputs, full_content = self._responses_for_item( + item, + response_id=response_id, + full_content=full_content, + ) + for output in outputs: + yield output + await self._send_metrics( + request, + response.get("usage", {}), + response.get("route", {}), + ttft_ms=None, + ) + yield LLMResponseMessageDone( + response_id=response_id, + role="assistant", + content=full_content, + created=int(time.time()), + ) + return + + async for event in self.client.stream( + payload, idempotency_key=idempotency_key + ): + if event.name == "response.created": + response_id = str( + event.data.get("response_id", response_id) + ) + elif event.name == "response.text.delta": + delta = str(event.data.get("delta", "")) + if not delta: + continue + if ttft_ms is None: + ttft_ms = int((time.monotonic() - started_at) * 1000) + full_content += delta + yield LLMResponseMessageDelta( + response_id=response_id, + role="assistant", + content=full_content, + delta=delta, + created=int(time.time()), + ) + elif event.name == "response.item.completed": + item = event.data.get("item", {}) + outputs, full_content = self._responses_for_item( + item, + response_id=response_id, + full_content=full_content, + ) + for output in outputs: + yield output + elif event.name == "response.completed": + await self._send_metrics( + request, + event.data.get("usage", {}), + self.client.route, + ttft_ms=ttft_ms, + ) + yield LLMResponseMessageDone( + response_id=response_id, + role="assistant", + content=full_content, + created=int(time.time()), + ) + except SpekoRouterError as error: + await self._report_router_error(error, request) + raise RuntimeError( + f"Speko Router request failed ({error.code}): {error.message}" + ) from error + + def _build_payload(self, request: LLMRequest) -> dict[str, Any]: + assert self.config is not None + input_items: list[dict[str, Any]] = [] + prompt = request.prompt or self.config.prompt + if prompt: + input_items.append(self._message_item("system", prompt)) + input_items.extend( + self._convert_message(item) for item in request.messages + ) + + parameters = dict(request.parameters or {}) + max_output_tokens = parameters.pop( + "max_output_tokens", + parameters.pop("max_tokens", self.config.max_output_tokens), + ) + payload: dict[str, Any] = { + "input": input_items, + "max_output_tokens": max_output_tokens, + "routing": self._routing_for_request(request, parameters), + } + tools = [self._convert_tool(tool) for tool in request.tools or []] + if tools: + payload["tools"] = tools + + temperature = parameters.pop("temperature", self.config.temperature) + top_p = parameters.pop("top_p", self.config.top_p) + response_format = parameters.pop("response_format", None) + if temperature is not None: + payload["temperature"] = temperature + if top_p is not None: + payload["top_p"] = top_p + if response_format is not None: + payload["response_format"] = response_format + if parameters: + self.ten_env.log_warn( + "Ignoring unsupported Speko LLM parameters: " + f"{sorted(parameters)}" + ) + return payload + + def _routing_for_request( + self, request: LLMRequest, parameters: dict[str, Any] + ) -> dict[str, Any]: + assert self.config is not None + override = parameters.pop("routing", None) + routing = dict(override or self.config.routing) + if not request.model: + return routing + if "/" in request.model: + return {"mode": "explicit", "model": request.model} + if routing.get("mode") == "explicit" and routing.get("provider"): + routing["model"] = request.model + return routing + raise ValueError( + "A bare TEN request model requires routing.provider; " + "otherwise use provider/model" + ) + + @staticmethod + def _message_item(role: str, text: str) -> dict[str, Any]: + return { + "type": "message", + "role": role, + "content": [{"type": "text", "text": text}], + } + + def _convert_message(self, message: Any) -> dict[str, Any]: + if isinstance(message, LLMMessageContent): + if isinstance(message.content, str): + text = message.content + else: + parts: list[str] = [] + for part in message.content: + if isinstance(part, TextContent): + parts.append(part.text) + elif isinstance(part, ImageContent): + raise ValueError( + "Speko Router LLM input currently supports text only" + ) + text = "".join(parts) + return self._message_item(message.role, text) + if isinstance(message, LLMMessageFunctionCall): + return { + "type": "function_call", + "call_id": message.call_id, + "name": message.name, + "arguments": message.arguments, + } + if isinstance(message, LLMMessageFunctionCallOutput): + return { + "type": "function_result", + "call_id": message.call_id, + "result": message.output, + } + raise ValueError(f"Unsupported TEN LLM message: {type(message)}") + + @staticmethod + def _convert_tool(tool: LLMToolMetadata) -> dict[str, Any]: + properties: dict[str, Any] = {} + required: list[str] = [] + for parameter in tool.parameters: + properties[parameter.name] = { + "type": parameter.type, + "description": parameter.description, + } + if parameter.required: + required.append(parameter.name) + return { + "name": tool.name, + "description": tool.description, + "parameters": { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + }, + } + + def _responses_for_item( + self, + item: Any, + *, + response_id: str, + full_content: str, + ) -> tuple[list[LLMResponse], str]: + if not isinstance(item, dict): + return [], full_content + item_type = item.get("type") + created = int(time.time()) + if item_type == "function_call": + arguments = json.loads(str(item.get("arguments", "{}"))) + return [ + LLMResponseToolCall( + response_id=response_id, + id=response_id, + tool_call_id=str(item.get("call_id", "")), + name=str(item.get("name", "")), + arguments=arguments, + created=created, + ) + ], full_content + + if item_type == "structured_json": + final_text = json.dumps( + item.get("json"), separators=(",", ":"), ensure_ascii=False + ) + elif item_type == "message": + final_text = "".join( + str(part.get("text", "")) + for part in item.get("content", []) + if isinstance(part, dict) and part.get("type") == "text" + ) + else: + return [], full_content + + if final_text == full_content: + return [], full_content + delta = ( + final_text[len(full_content) :] + if final_text.startswith(full_content) + else final_text + ) + return [ + LLMResponseMessageDelta( + response_id=response_id, + role="assistant", + content=final_text, + delta=delta, + created=created, + ) + ], final_text + + async def _send_metrics( + self, + request: LLMRequest, + usage: Any, + route: Any, + *, + ttft_ms: int | None, + ) -> None: + metrics: dict[str, Any] = {"usage": usage or {}} + if ttft_ms is not None: + metrics[ModuleMetricKey.LLM_TTFT] = ttft_ms + data = Data.create("metrics") + data.set_property_from_json( + None, + ModuleMetrics( + id=uuid.uuid4().hex, + module=ModuleType.LLM, + vendor="speko", + metrics=metrics, + metadata={ + "request_id": request.request_id, + "route": route or {}, + }, + ).model_dump_json(), + ) + await self.ten_env.send_data(data) + + async def _report_router_error( + self, error: SpekoRouterError, request: LLMRequest + ) -> None: + self.ten_env.log_error( + f"vendor_error: code={error.code}, message={error.message}", + category=LOG_CATEGORY_VENDOR, + ) + await self._send_error( + ModuleError( + id=request.request_id, + module=ModuleType.LLM, + code=self._module_error_code(error).value, + message=error.message, + vendor_info=ModuleErrorVendorInfo( + vendor="speko", + code=error.code, + message=error.message, + ), + ) + ) + + async def _send_error(self, error: ModuleError) -> None: + data = Data.create("error") + data.set_property_from_json(None, error.model_dump_json()) + await self.ten_env.send_data(data) + + @staticmethod + def _module_error_code(error: SpekoRouterError) -> ModuleErrorCode: + fatal_codes = {"authentication_failed", "insufficient_credit"} + if error.code in fatal_codes: + return ModuleErrorCode.FATAL_ERROR + return ModuleErrorCode.NON_FATAL_ERROR diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/manifest.json b/ai_agents/agents/ten_packages/extension/speko_llm2_python/manifest.json new file mode 100644 index 0000000000..52f17b5119 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/manifest.json @@ -0,0 +1,62 @@ +{ + "type": "extension", + "name": "speko_llm2_python", + "version": "0.1.0", + "display_name": { + "locales": { + "en-US": { + "content": "Speko Router LLM2 Python Extension" + } + } + }, + "description": { + "locales": { + "en-US": { + "content": "Language model responses routed through Speko" + } + } + }, + "tags": ["python", "speko", "llm", "router"], + "dependencies": [ + { + "type": "system", + "name": "ten_runtime_python", + "version": "0.11" + }, + { + "type": "system", + "name": "ten_ai_base", + "version": "0.7" + } + ], + "api": { + "interface": [ + { + "import_uri": "../../system/ten_ai_base/api/llm-interface.json" + } + ], + "property": { + "properties": { + "api_key": {"type": "string"}, + "base_url": {"type": "string"}, + "prompt": {"type": "string"}, + "max_output_tokens": {"type": "int32"}, + "temperature": {"type": "float64"}, + "top_p": {"type": "float64"}, + "timeout_sec": {"type": "float64"}, + "routing": {"type": "object", "properties": {}} + } + } + }, + "scripts": {"test": "tests/bin/start"}, + "package": { + "include": [ + "manifest.json", + "property.json", + "**.py", + "README.md", + "pyproject.toml", + "requirements.txt" + ] + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/property.json b/ai_agents/agents/ten_packages/extension/speko_llm2_python/property.json new file mode 100644 index 0000000000..316dda68c0 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/property.json @@ -0,0 +1,12 @@ +{ + "api_key": "${env:SPEKO_API_KEY|}", + "base_url": "https://router.speko.dev", + "prompt": "You are a helpful assistant.", + "max_output_tokens": 512, + "temperature": 0.7, + "timeout_sec": 60.0, + "routing": { + "mode": "auto", + "objective": "balanced" + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml new file mode 100644 index 0000000000..e182a06cd8 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "speko-llm2-python" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.27.0,<1", + "pydantic>=2.13.4", +] diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_llm2_python/requirements.txt new file mode 100644 index 0000000000..b41183b342 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/requirements.txt @@ -0,0 +1,2 @@ +httpx>=0.27.0,<1 +pydantic>=2.13.4 diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start new file mode 100755 index 0000000000..0a273c6ea9 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +export PYTHONPATH=".:${PYTHONPATH}" +pytest -q tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py new file mode 100644 index 0000000000..93300358db --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path +from types import ModuleType + +package_name = Path(__file__).parents[1].name +addon_name = f"{package_name}.addon" +sys.modules.setdefault(addon_name, ModuleType(addon_name)) diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py new file mode 100644 index 0000000000..fa3461fb0e --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py @@ -0,0 +1,143 @@ +import json + +import httpx +import pytest +from client import SpekoLLMClient, SpekoRouterError + + +def sse(*events): + return "".join( + f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events + ) + + +@pytest.mark.asyncio +async def test_stream_maps_sse_and_sends_idempotency_key(): + captured = {} + + async def handler(request): + captured["request"] = request + return httpx.Response( + 200, + text=sse( + ("response.created", {"response_id": "resp_req_1"}), + ( + "response.text.delta", + {"output_index": 0, "delta": "hello"}, + ), + ( + "response.completed", + { + "stop_reason": "stop", + "usage": {"input_tokens": 2, "output_tokens": 1}, + }, + ), + ), + headers={ + "content-type": "text/event-stream", + "Speko-Provider": "openai", + "Speko-Model": "gpt-5-mini", + "Speko-Region": "us-east", + "Speko-Attempt-ID": "att_1", + }, + ) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = SpekoLLMClient( + api_key="test-key", + base_url="https://router.speko.dev", + timeout_sec=1, + http_client=http_client, + ) + events = [ + event + async for event in client.stream( + {"input": [], "max_output_tokens": 32, "stream": True}, + idempotency_key="idem-1", + ) + ] + + assert [event.name for event in events] == [ + "response.created", + "response.text.delta", + "response.completed", + ] + request = captured["request"] + assert request.headers["authorization"] == "Bearer test-key" + assert request.headers["idempotency-key"] == "idem-1" + assert json.loads(request.content)["max_output_tokens"] == 32 + assert client.route == { + "provider": "openai", + "model": "gpt-5-mini", + "region": "us-east", + "attempt_id": "att_1", + } + await http_client.aclose() + + +@pytest.mark.asyncio +async def test_http_error_is_normalized(): + async def handler(request): + del request + return httpx.Response( + 429, + json={ + "error": { + "code": "rate_limited", + "message": "slow down", + "hint": "retry", + "retryable": True, + } + }, + ) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = SpekoLLMClient( + api_key="test-key", + base_url="https://router.speko.dev", + timeout_sec=1, + http_client=http_client, + ) + with pytest.raises(SpekoRouterError) as caught: + _ = [ + event + async for event in client.stream( + {"input": [], "max_output_tokens": 32, "stream": True}, + idempotency_key="idem-1", + ) + ] + + assert caught.value.code == "rate_limited" + assert caught.value.retryable is True + assert caught.value.status_code == 429 + await http_client.aclose() + + +@pytest.mark.asyncio +async def test_truncated_stream_is_not_reported_as_success(): + async def handler(request): + del request + return httpx.Response( + 200, + text=sse(("response.created", {"response_id": "resp_req_1"})), + ) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = SpekoLLMClient( + api_key="test-key", + base_url="https://router.speko.dev", + timeout_sec=1, + http_client=http_client, + ) + with pytest.raises(SpekoRouterError) as caught: + _ = [ + event + async for event in client.stream( + {"input": [], "max_output_tokens": 32, "stream": True}, + idempotency_key="idem-1", + ) + ] + + assert caught.value.code == "relay_error" + assert caught.value.retryable is True + await http_client.aclose() diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/uv.lock b/ai_agents/agents/ten_packages/extension/speko_llm2_python/uv.lock new file mode 100644 index 0000000000..050288ff23 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/uv.lock @@ -0,0 +1,260 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "speko-llm2-python" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.0,<1" }, + { name = "pydantic", specifier = ">=2.13.4" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md new file mode 100644 index 0000000000..cdda53b4c8 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md @@ -0,0 +1,39 @@ +# Speko Router TTS2 extension + +Routes TEN text-to-speech requests through Speko and emits PCM audio through +the standard TEN TTS2 interface. Each TEN text chunk becomes one Speko +utterance, so sentence-level streaming starts before `text_input_end`. + +## Configuration + +Set `SPEKO_API_KEY`, then use the extension defaults or override `params` in +your graph. + +| Property | Default | Description | +| --- | --- | --- | +| `params.base_url` | `https://router.speko.dev` | Speko Router origin | +| `params.sample_rate` | `24000` | PCM output sample rate | +| `params.channels` | `1` | PCM output channel count | +| `params.language` | `en` | Optional routing/voice hint | +| `params.voice` | empty | Optional provider voice identifier | +| `params.routing` | auto, balanced | Speko routing selection | + +Output is raw signed 16-bit little-endian PCM. TEN `tts_flush` maps to Speko +`input.cancel`; no audio is forwarded after the flush completes. + +```json +{ + "type": "extension", + "name": "tts", + "addon": "speko_tts2_python", + "extension_group": "tts", + "property": { + "params": { + "api_key": "${env:SPEKO_API_KEY}", + "routing": {"mode": "auto", "objective": "quality"}, + "language": "en", + "sample_rate": 24000 + } + } +} +``` diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/__init__.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/__init__.py new file mode 100644 index 0000000000..00ba4e1293 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/__init__.py @@ -0,0 +1,6 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +from . import addon as addon diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/addon.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/addon.py new file mode 100644 index 0000000000..6ecf8543bc --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/addon.py @@ -0,0 +1,15 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +from ten_runtime import Addon, TenEnv, register_addon_as_extension + + +@register_addon_as_extension("speko_tts2_python") +class SpekoTTS2ExtensionAddon(Addon): + def on_create_instance(self, ten_env: TenEnv, name: str, context) -> None: + from .extension import SpekoTTS2Extension + + ten_env.log_info("SpekoTTS2ExtensionAddon on_create_instance") + ten_env.on_create_instance_done(SpekoTTS2Extension(name), context) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py new file mode 100644 index 0000000000..14e97f4db3 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py @@ -0,0 +1,276 @@ +import asyncio +import json +import time +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass +from enum import Enum +from typing import Any +from urllib.parse import urlparse, urlunparse + +import websockets +from websockets.asyncio.client import ClientConnection +from websockets.exceptions import InvalidStatus + + +class SpekoRouterError(Exception): + def __init__( + self, + code: str, + message: str, + *, + retryable: bool = False, + request_id: str = "", + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retryable = retryable + self.request_id = request_id + + @classmethod + def from_event(cls, event: dict[str, Any]) -> "SpekoRouterError": + error = event.get("error", {}) + return cls( + str(error.get("code", "relay_error")), + str(error.get("message", "Speko Router error")), + retryable=bool(error.get("retryable", False)), + request_id=str(error.get("request_id", "")), + ) + + +class SpekoTTSEventType(str, Enum): + AUDIO = "audio" + TTFB = "ttfb" + DONE = "done" + USAGE = "usage" + + +@dataclass(frozen=True) +class SpekoTTSEvent: + type: SpekoTTSEventType + value: bytes | int | dict[str, Any] | None = None + + +def websocket_url(base_url: str, path: str) -> str: + parsed = urlparse(base_url) + scheme = {"https": "wss", "http": "ws"}.get(parsed.scheme, parsed.scheme) + base_path = parsed.path.rstrip("/") + return urlunparse((scheme, parsed.netloc, f"{base_path}{path}", "", "", "")) + + +class SpekoTTSClient: + def __init__( + self, + *, + api_key: str, + base_url: str, + configure: dict[str, Any], + ready_timeout_sec: float, + receive_timeout_sec: float, + ) -> None: + self.api_key = api_key + self.url = websocket_url(base_url, "/v1/tts/stream") + self.configure = configure + self.ready_timeout_sec = ready_timeout_sec + self.receive_timeout_sec = receive_timeout_sec + + self.request_id = "" + self.route: dict[str, Any] = {} + self.usage: dict[str, Any] = {} + self._ws: ClientConnection | None = None + self._ready = False + + @property + def is_ready(self) -> bool: + return self._ready and self._ws is not None + + async def connect(self) -> None: + if self.is_ready: + return + headers = { + "Authorization": f"Bearer {self.api_key}", + "Idempotency-Key": uuid.uuid4().hex, + } + try: + self._ws = await websockets.connect( + self.url, + additional_headers=headers, + open_timeout=self.ready_timeout_sec, + ) + await self._ws.send( + json.dumps(self.configure, separators=(",", ":")) + ) + raw = await asyncio.wait_for( + self._ws.recv(), timeout=self.ready_timeout_sec + ) + event = self._decode_event(raw) + if event.get("type") == "error": + raise SpekoRouterError.from_event(event) + if event.get("type") != "session.ready": + raise SpekoRouterError( + "relay_error", + "Expected session.ready from Speko Router", + retryable=True, + ) + self.request_id = str(event.get("request_id", "")) + self.route = dict(event.get("route", {})) + self._ready = True + except InvalidStatus as error: + status = error.response.status_code + code = ( + "authentication_failed" + if status in (401, 403) + else "relay_error" + ) + await self._close_socket() + raise SpekoRouterError( + code, + f"Speko Router WebSocket upgrade failed ({status})", + retryable=status >= 500 or status == 429, + ) from error + except Exception: + await self._close_socket() + raise + + async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: + if not self.is_ready or self._ws is None: + raise SpekoRouterError( + "relay_error", "Speko TTS session is not ready", retryable=True + ) + + await self._ws.send( + json.dumps( + {"type": "input.append", "text": text}, + separators=(",", ":"), + ) + ) + await self._ws.send('{"type":"input.commit"}') + sent_at = time.monotonic() + sequence: int | None = None + first_audio = True + + while True: + try: + raw = await asyncio.wait_for( + self._ws.recv(), timeout=self.receive_timeout_sec + ) + except asyncio.TimeoutError as error: + raise SpekoRouterError( + "request_timeout", + "Timed out waiting for Speko TTS audio", + retryable=True, + ) from error + + if isinstance(raw, bytes): + if sequence is None: + raise SpekoRouterError( + "relay_error", + "Received TTS audio before utterance.started", + retryable=True, + ) + if first_audio: + first_audio = False + yield SpekoTTSEvent( + SpekoTTSEventType.TTFB, + int((time.monotonic() - sent_at) * 1000), + ) + yield SpekoTTSEvent(SpekoTTSEventType.AUDIO, raw) + continue + + event = self._decode_event(raw) + event_type = event.get("type") + if event_type == "error": + self._ready = False + raise SpekoRouterError.from_event(event) + if event_type == "utterance.started": + sequence = int(event["sequence"]) + elif event_type == "utterance.done": + if sequence is None or int(event["sequence"]) != sequence: + raise SpekoRouterError( + "relay_error", + "Speko TTS utterance sequence mismatch", + retryable=True, + ) + yield SpekoTTSEvent(SpekoTTSEventType.DONE) + return + elif event_type == "usage.updated": + self.usage = dict(event.get("usage", {})) + yield SpekoTTSEvent(SpekoTTSEventType.USAGE, self.usage) + elif event_type == "session.closed": + self.usage = dict(event.get("usage", {})) + self._ready = False + raise SpekoRouterError( + "relay_error", + "Speko TTS session closed before utterance.done", + retryable=True, + ) + + async def cancel(self) -> None: + if self._ws is not None and self._ready: + try: + await self._ws.send('{"type":"input.cancel"}') + except Exception: + pass + await self.close(drain=False) + + async def close(self, *, drain: bool = True) -> None: + try: + if self._ws is not None and self._ready: + try: + await self._ws.send('{"type":"session.close"}') + except Exception: + pass + if drain: + await self._drain_close() + finally: + await self._close_socket() + + async def _drain_close(self) -> None: + assert self._ws is not None + try: + while True: + raw = await asyncio.wait_for(self._ws.recv(), timeout=2.0) + if isinstance(raw, bytes): + continue + event = self._decode_event(raw) + event_type = event.get("type") + if event_type in {"usage.updated", "session.closed"}: + self.usage = dict(event.get("usage", {})) + if event_type == "error": + raise SpekoRouterError.from_event(event) + if event_type == "session.closed": + return + except asyncio.TimeoutError: + return + + async def _close_socket(self) -> None: + self._ready = False + if self._ws is not None: + try: + await self._ws.close() + except Exception: + pass + self._ws = None + + @staticmethod + def _decode_event(raw: str | bytes) -> dict[str, Any]: + if not isinstance(raw, str): + raise SpekoRouterError( + "relay_error", "Expected a JSON text frame", retryable=True + ) + try: + event = json.loads(raw) + except json.JSONDecodeError as error: + raise SpekoRouterError( + "relay_error", + "Speko Router returned invalid JSON", + retryable=True, + ) from error + if not isinstance(event, dict): + raise SpekoRouterError( + "relay_error", + "Speko Router event must be a JSON object", + retryable=True, + ) + return event diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py new file mode 100644 index 0000000000..914eac9f53 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py @@ -0,0 +1,92 @@ +import copy +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from ten_ai_base import utils + + +class SpekoTTS2Config(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + dump: bool = False + dump_path: str = "/tmp" + params: dict[str, Any] = Field(default_factory=dict) + + api_key: str = "" + base_url: str = "https://router.speko.dev" + sample_rate: int = 24000 + channels: int = 1 + language: str = "en" + voice: str = "" + routing: dict[str, Any] = Field( + default_factory=lambda: { + "mode": "auto", + "objective": "balanced", + } + ) + ready_timeout_sec: float = 10.0 + receive_timeout_sec: float = 30.0 + + @field_validator("sample_rate") + @classmethod + def validate_sample_rate(cls, value: int) -> int: + if not 8000 <= value <= 192000: + raise ValueError("sample_rate must be between 8000 and 192000") + return value + + @field_validator("channels") + @classmethod + def validate_channels(cls, value: int) -> int: + if not 1 <= value <= 8: + raise ValueError("channels must be between 1 and 8") + return value + + def update_params(self) -> None: + values = dict(self.params) + for name in ( + "api_key", + "base_url", + "sample_rate", + "channels", + "language", + "voice", + "routing", + "ready_timeout_sec", + "receive_timeout_sec", + ): + if name in values: + setattr(self, name, values.pop(name)) + self.params = values + self._validate_required() + + def _validate_required(self) -> None: + if not self.api_key.strip(): + raise ValueError("api_key is required") + if not self.base_url.strip(): + raise ValueError("base_url is required") + self._validate_routing() + + def _validate_routing(self) -> None: + # pylint: disable=no-member + mode = self.routing.get("mode", "auto") + if mode == "auto": + objective = self.routing.get("objective", "balanced") + if objective not in {"balanced", "quality", "latency", "cost"}: + raise ValueError("unsupported routing objective") + return + if mode == "explicit": + model = str(self.routing.get("model", "")) + provider = str(self.routing.get("provider", "")) + if not model or (not provider and "/" not in model): + raise ValueError( + "explicit routing requires provider and model, " + "or a provider/model value" + ) + return + raise ValueError("routing mode must be auto or explicit") + + def to_str(self, sensitive_handling: bool = True) -> str: + config = copy.deepcopy(self) + if sensitive_handling and config.api_key: + config.api_key = utils.encrypt(config.api_key) + return f"{config}" diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py new file mode 100644 index 0000000000..5abbe734b9 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py @@ -0,0 +1,360 @@ +# +# This file is part of TEN Framework, an open source project. +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file for more information. +# +import os +import time +import traceback +from typing import Any + +from ten_ai_base.const import LOG_CATEGORY_KEY_POINT, LOG_CATEGORY_VENDOR +from ten_ai_base.helper import PCMWriter +from ten_ai_base.message import ( + ModuleError, + ModuleErrorCode, + ModuleErrorVendorInfo, + ModuleType, + TTSAudioEndReason, +) +from ten_ai_base.struct import TTSTextInput +from ten_ai_base.tts2 import AsyncTTS2BaseExtension +from ten_runtime import AsyncTenEnv + +from .client import ( + SpekoRouterError, + SpekoTTSClient, + SpekoTTSEventType, +) +from .config import SpekoTTS2Config + + +class SpekoTTS2Extension(AsyncTTS2BaseExtension): + def __init__(self, name: str) -> None: + super().__init__(name) + self.config: SpekoTTS2Config | None = None + self.client: SpekoTTSClient | None = None + self.current_request_id: str | None = None + self._route: dict[str, Any] = {} + self._router_usage: dict[str, Any] = {} + self._audio_start_sent = False + self._first_audio_at: float | None = None + self._total_audio_bytes = 0 + self._finalized = False + self._stopped = False + self._recorders: dict[str, PCMWriter] = {} + + async def on_init(self, ten_env: AsyncTenEnv) -> None: + await super().on_init(ten_env) + try: + config_json, error = await ten_env.get_property_to_json("") + if error: + raise RuntimeError(f"Failed to read configuration: {error}") + self.config = SpekoTTS2Config.model_validate_json(config_json) + self.config.update_params() + ten_env.log_info( + f"config: {self.config.to_str(sensitive_handling=True)}", + category=LOG_CATEGORY_KEY_POINT, + ) + except Exception as error: + self.config = None + ten_env.log_error( + f"invalid property: {error}", + category=LOG_CATEGORY_KEY_POINT, + ) + await self.send_tts_error( + request_id="", + error=ModuleError( + module=ModuleType.TTS, + code=ModuleErrorCode.FATAL_ERROR.value, + message=str(error), + vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + ), + ) + + async def on_stop(self, ten_env: AsyncTenEnv) -> None: + self._stopped = True + await self._close_client() + for recorder in self._recorders.values(): + await recorder.flush() + self._recorders.clear() + await super().on_stop(ten_env) + + def vendor(self) -> str: + return "speko" + + def vendor_metadata(self) -> dict[str, Any]: + if self.config is None: + return {} + metadata: dict[str, Any] = { + "base_url": self.config.base_url, + "routing": self.config.routing, + } + if self._route: + metadata["route"] = self._route + metadata["model"] = self._route.get("model", "") + return metadata + + def synthesize_audio_sample_rate(self) -> int: + return self.config.sample_rate if self.config else 24000 + + def synthesize_audio_channels(self) -> int: + return self.config.channels if self.config else 1 + + async def request_tts(self, text_input: TTSTextInput) -> None: + if self.config is None or self._stopped: + return + + try: + if text_input.request_id != self.current_request_id: + await self._begin_request(text_input) + if self._finalized: + self.ten_env.log_warn( + "Ignoring text for a completed Speko TTS request" + ) + return + + text = text_input.text.strip() + if text: + await self._stream_text(text, text_input.request_id) + + if text_input.text_input_end: + await self._finalize_request(TTSAudioEndReason.REQUEST_END) + except SpekoRouterError as error: + await self._handle_router_error(error) + except Exception as error: + self.ten_env.log_error( + f"Speko TTS request failed: {traceback.format_exc()}" + ) + await self.on_disconnected( + code=ModuleErrorCode.NON_FATAL_ERROR.value, + message=str(error), + vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + ) + await self._finalize_request( + TTSAudioEndReason.ERROR, + error=ModuleError( + module=ModuleType.TTS, + code=ModuleErrorCode.NON_FATAL_ERROR.value, + message=str(error), + vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + ), + ) + + async def cancel_tts(self) -> None: + request_id = self.current_request_id + if request_id is None or self._finalized: + return + self._finalized = True + if self.client is not None: + await self.client.cancel() + self.client = None + await self.on_disconnected(code=0, message="cancelled") + await self._ensure_audio_start() + await self.send_tts_audio_end( + request_id=request_id, + request_event_interval_ms=self._request_interval_ms(), + request_total_audio_duration_ms=self._audio_duration_ms(), + reason=TTSAudioEndReason.INTERRUPTED, + ) + await self._flush_recorder(request_id) + + async def _begin_request(self, text_input: TTSTextInput) -> None: + if self.client is not None: + await self._close_client() + self.current_request_id = text_input.request_id + self._route = {} + self._router_usage = {} + self._audio_start_sent = False + self._first_audio_at = None + self._total_audio_bytes = 0 + self._finalized = False + await self._setup_recorder(text_input.request_id) + + async def _ensure_client(self) -> SpekoTTSClient: + if self.client is not None and self.client.is_ready: + return self.client + if self.config is None: + raise RuntimeError("Speko TTS is not configured") + + await self.on_connecting() + connected_at = time.monotonic() + client = SpekoTTSClient( + api_key=self.config.api_key, + base_url=self.config.base_url, + configure=self._configure_frame(), + ready_timeout_sec=self.config.ready_timeout_sec, + receive_timeout_sec=self.config.receive_timeout_sec, + ) + await client.connect() + self.client = client + self._route = client.route + await self.on_connected() + await self.metrics_connect_delay( + int((time.monotonic() - connected_at) * 1000), + extra_metadata={"route": self._route}, + request_id=self.current_request_id or "", + ) + self.ten_env.log_info( + f"vendor_status_changed: Speko TTS ready; route={self._route}", + category=LOG_CATEGORY_VENDOR, + ) + return client + + async def _stream_text(self, text: str, request_id: str) -> None: + client = await self._ensure_client() + self.metrics_add_input_characters(len(text)) + async for event in client.stream_text(text): + if event.type == SpekoTTSEventType.TTFB: + if not self._audio_start_sent: + await self._ensure_audio_start() + await self.send_tts_ttfb_metrics( + request_id=request_id, + ttfb_ms=int(event.value or 0), + extra_metadata={"route": self._route}, + ) + elif event.type == SpekoTTSEventType.AUDIO: + audio = event.value + if not isinstance(audio, bytes) or not audio: + continue + await self._ensure_audio_start() + if self._first_audio_at is None: + self._first_audio_at = time.monotonic() + self._total_audio_bytes += len(audio) + self.metrics_add_recv_audio_chunks(audio) + await self._write_dump(request_id, audio) + await self.send_tts_audio_data(audio) + elif event.type == SpekoTTSEventType.USAGE: + if isinstance(event.value, dict): + self._router_usage = event.value + + async def _finalize_request( + self, + reason: TTSAudioEndReason, + error: ModuleError | None = None, + ) -> None: + request_id = self.current_request_id + if request_id is None or self._finalized: + return + self._finalized = True + await self._ensure_audio_start() + await self.send_tts_audio_end( + request_id=request_id, + request_event_interval_ms=self._request_interval_ms(), + request_total_audio_duration_ms=self._audio_duration_ms(), + reason=reason, + extra_metadata={"router_usage": self._router_usage}, + ) + await self._close_client() + await self.send_usage_metrics( + request_id, + extra_metadata={"router_usage": self._router_usage}, + ) + await self._flush_recorder(request_id) + await self.finish_request(request_id, reason=reason, error=error) + + async def _handle_router_error(self, error: SpekoRouterError) -> None: + self.ten_env.log_error( + f"vendor_error: code={error.code}, message={error.message}", + category=LOG_CATEGORY_VENDOR, + ) + module_code = self._module_error_code(error) + module_error = ModuleError( + module=ModuleType.TTS, + code=module_code.value, + message=error.message, + vendor_info=ModuleErrorVendorInfo( + vendor=self.vendor(), + code=error.code, + message=error.message, + ), + ) + await self.on_disconnected( + code=module_code.value, + message=error.message, + vendor_info=module_error.vendor_info, + ) + if self._finalized: + await self.send_tts_error(self.current_request_id, module_error) + return + await self._finalize_request( + TTSAudioEndReason.ERROR, + error=module_error, + ) + + async def _ensure_audio_start(self) -> None: + if self._audio_start_sent or self.current_request_id is None: + return + await self.send_tts_audio_start( + request_id=self.current_request_id, + extra_metadata={"route": self._route}, + ) + self._audio_start_sent = True + + async def _close_client(self) -> None: + if self.client is None: + return + client = self.client + try: + await client.close() + finally: + self._router_usage = client.usage or self._router_usage + self.client = None + await self.on_disconnected(code=0, message="closed") + + def _configure_frame(self) -> dict[str, Any]: + assert self.config is not None + frame: dict[str, Any] = { + "type": "session.configure", + "audio": { + "encoding": "pcm_s16le", + "sample_rate_hz": self.config.sample_rate, + "channels": self.config.channels, + }, + } + if self.config.routing: + frame["routing"] = self.config.routing + if self.config.language: + frame["language"] = self.config.language + if self.config.voice: + frame["voice"] = self.config.voice + return frame + + async def _setup_recorder(self, request_id: str) -> None: + if self.config is None or not self.config.dump: + return + os.makedirs(self.config.dump_path, exist_ok=True) + path = os.path.join( + self.config.dump_path, f"speko_tts_{request_id}.pcm" + ) + self._recorders[request_id] = PCMWriter(path) + + async def _write_dump(self, request_id: str, audio: bytes) -> None: + recorder = self._recorders.get(request_id) + if recorder is not None: + await recorder.write(audio) + + async def _flush_recorder(self, request_id: str) -> None: + recorder = self._recorders.pop(request_id, None) + if recorder is not None: + await recorder.flush() + + def _request_interval_ms(self) -> int: + if self._first_audio_at is None: + return 0 + return int((time.monotonic() - self._first_audio_at) * 1000) + + def _audio_duration_ms(self) -> int: + bytes_per_second = ( + self.synthesize_audio_sample_rate() + * self.synthesize_audio_channels() + * self.synthesize_audio_sample_width() + ) + return int(self._total_audio_bytes * 1000 / bytes_per_second) + + @staticmethod + def _module_error_code(error: SpekoRouterError) -> ModuleErrorCode: + fatal_codes = {"authentication_failed", "insufficient_credit"} + if error.code in fatal_codes: + return ModuleErrorCode.FATAL_ERROR + return ModuleErrorCode.NON_FATAL_ERROR diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/manifest.json b/ai_agents/agents/ten_packages/extension/speko_tts2_python/manifest.json new file mode 100644 index 0000000000..d7e92b9725 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/manifest.json @@ -0,0 +1,70 @@ +{ + "type": "extension", + "name": "speko_tts2_python", + "version": "0.1.0", + "display_name": { + "locales": { + "en-US": { + "content": "Speko Router TTS2 Python Extension" + } + } + }, + "description": { + "locales": { + "en-US": { + "content": "Streaming speech synthesis routed through Speko" + } + } + }, + "tags": ["python", "speko", "tts", "router"], + "dependencies": [ + { + "type": "system", + "name": "ten_runtime_python", + "version": "0.11" + }, + { + "type": "system", + "name": "ten_ai_base", + "version": "0.7" + } + ], + "api": { + "interface": [ + { + "import_uri": "../../system/ten_ai_base/api/tts-interface.json" + } + ], + "property": { + "properties": { + "dump": {"type": "bool"}, + "dump_path": {"type": "string"}, + "params": { + "type": "object", + "properties": { + "api_key": {"type": "string"}, + "base_url": {"type": "string"}, + "sample_rate": {"type": "int32"}, + "channels": {"type": "int32"}, + "language": {"type": "string"}, + "voice": {"type": "string"}, + "routing": {"type": "object", "properties": {}}, + "ready_timeout_sec": {"type": "float64"}, + "receive_timeout_sec": {"type": "float64"} + } + } + } + } + }, + "scripts": {"test": "tests/bin/start"}, + "package": { + "include": [ + "manifest.json", + "property.json", + "**.py", + "README.md", + "pyproject.toml", + "requirements.txt" + ] + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/property.json b/ai_agents/agents/ten_packages/extension/speko_tts2_python/property.json new file mode 100644 index 0000000000..29470e2453 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/property.json @@ -0,0 +1,18 @@ +{ + "dump": false, + "dump_path": "/tmp", + "params": { + "api_key": "${env:SPEKO_API_KEY|}", + "base_url": "https://router.speko.dev", + "sample_rate": 24000, + "channels": 1, + "language": "en", + "voice": "", + "routing": { + "mode": "auto", + "objective": "balanced" + }, + "ready_timeout_sec": 10.0, + "receive_timeout_sec": 30.0 + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml new file mode 100644 index 0000000000..157058b75d --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "speko-tts2-python" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "pydantic>=2.13.4", + "websockets>=14.0,<16", +] diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_tts2_python/requirements.txt new file mode 100644 index 0000000000..b31d982acc --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/requirements.txt @@ -0,0 +1,2 @@ +pydantic>=2.13.4 +websockets>=14.0,<16 diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start new file mode 100755 index 0000000000..0a273c6ea9 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +export PYTHONPATH=".:${PYTHONPATH}" +pytest -q tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_basic_audio_setting1.json b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_basic_audio_setting1.json new file mode 100644 index 0000000000..e55ba6c0b3 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_basic_audio_setting1.json @@ -0,0 +1,9 @@ +{ + "dump": true, + "dump_path": "/tmp/speko_tts_guarder", + "params": { + "api_key": "${env:SPEKO_API_KEY|}", + "sample_rate": 16000, + "channels": 1 + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_basic_audio_setting2.json b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_basic_audio_setting2.json new file mode 100644 index 0000000000..03d3bea518 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_basic_audio_setting2.json @@ -0,0 +1,9 @@ +{ + "dump": true, + "dump_path": "/tmp/speko_tts_guarder", + "params": { + "api_key": "${env:SPEKO_API_KEY|}", + "sample_rate": 24000, + "channels": 1 + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_dump.json b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_dump.json new file mode 100644 index 0000000000..03d3bea518 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_dump.json @@ -0,0 +1,9 @@ +{ + "dump": true, + "dump_path": "/tmp/speko_tts_guarder", + "params": { + "api_key": "${env:SPEKO_API_KEY|}", + "sample_rate": 24000, + "channels": 1 + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_invalid.json b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_invalid.json new file mode 100644 index 0000000000..1e97cf8d7b --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_invalid.json @@ -0,0 +1,5 @@ +{ + "params": { + "api_key": "invalid" + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_miss_required.json b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_miss_required.json new file mode 100644 index 0000000000..c43c2d9af5 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/configs/property_miss_required.json @@ -0,0 +1,5 @@ +{ + "params": { + "api_key": "" + } +} diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py new file mode 100644 index 0000000000..93300358db --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path +from types import ModuleType + +package_name = Path(__file__).parents[1].name +addon_name = f"{package_name}.addon" +sys.modules.setdefault(addon_name, ModuleType(addon_name)) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py new file mode 100644 index 0000000000..d3bdda0d5f --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py @@ -0,0 +1,195 @@ +import asyncio +import json +from unittest.mock import AsyncMock, patch + +import pytest +from client import ( + SpekoRouterError, + SpekoTTSClient, + SpekoTTSEventType, +) + + +class FakeWebSocket: + def __init__(self, *messages): + self.messages = asyncio.Queue() + for message in messages: + self.messages.put_nowait(message) + self.sent = [] + self.closed = False + + async def send(self, value): + self.sent.append(value) + + async def recv(self): + return await self.messages.get() + + async def close(self): + self.closed = True + + +def make_client(): + return SpekoTTSClient( + api_key="test-key", + base_url="https://router.speko.dev", + configure={ + "type": "session.configure", + "audio": { + "encoding": "pcm_s16le", + "sample_rate_hz": 24000, + "channels": 1, + }, + }, + ready_timeout_sec=1, + receive_timeout_sec=1, + ) + + +@pytest.mark.asyncio +async def test_append_commit_audio_and_usage(): + websocket = FakeWebSocket( + json.dumps( + { + "type": "session.ready", + "request_id": "req_1", + "route": { + "provider": "cartesia", + "model": "sonic-3", + "region": "us", + "attempt_id": "att_1", + }, + } + ), + json.dumps({"type": "utterance.started", "sequence": 1}), + b"pcm-audio", + json.dumps({"type": "usage.updated", "usage": {"characters": 5}}), + json.dumps({"type": "utterance.done", "sequence": 1}), + json.dumps({"type": "session.closed", "usage": {"characters": 5}}), + ) + client = make_client() + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + events = [event async for event in client.stream_text("hello")] + + assert [event.type for event in events] == [ + SpekoTTSEventType.TTFB, + SpekoTTSEventType.AUDIO, + SpekoTTSEventType.USAGE, + SpekoTTSEventType.DONE, + ] + assert events[1].value == b"pcm-audio" + assert client.usage == {"characters": 5} + assert json.loads(websocket.sent[1]) == { + "type": "input.append", + "text": "hello", + } + assert json.loads(websocket.sent[2]) == {"type": "input.commit"} + await client.close() + + +@pytest.mark.asyncio +async def test_cancel_sends_protocol_cancel_before_close(): + websocket = FakeWebSocket( + json.dumps( + { + "type": "session.ready", + "request_id": "req_1", + "route": { + "provider": "x", + "model": "y", + "region": "us", + "attempt_id": "att_1", + }, + } + ) + ) + client = make_client() + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + await client.cancel() + + sent_types = [json.loads(value)["type"] for value in websocket.sent] + assert sent_types == [ + "session.configure", + "input.cancel", + "session.close", + ] + assert websocket.closed is True + + +@pytest.mark.asyncio +async def test_router_error_keeps_retryable_flag(): + websocket = FakeWebSocket( + json.dumps( + { + "type": "session.ready", + "request_id": "req_1", + "route": { + "provider": "x", + "model": "y", + "region": "us", + "attempt_id": "att_1", + }, + } + ), + json.dumps( + { + "type": "error", + "error": { + "code": "rate_limited", + "message": "slow down", + "hint": "retry", + "retryable": True, + }, + } + ), + ) + client = make_client() + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + with pytest.raises(SpekoRouterError) as caught: + _ = [event async for event in client.stream_text("hello")] + + assert caught.value.code == "rate_limited" + assert caught.value.retryable is True + + +@pytest.mark.asyncio +async def test_close_surfaces_terminal_router_error(): + websocket = FakeWebSocket( + json.dumps( + { + "type": "session.ready", + "request_id": "req_1", + "route": { + "provider": "x", + "model": "y", + "region": "us", + "attempt_id": "att_1", + }, + } + ), + json.dumps({"type": "utterance.started", "sequence": 1}), + b"pcm", + json.dumps({"type": "utterance.done", "sequence": 1}), + json.dumps( + { + "type": "error", + "error": { + "code": "provider_error", + "message": "settlement failed", + "hint": "retry", + "retryable": True, + }, + } + ), + ) + client = make_client() + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + _ = [event async for event in client.stream_text("hello")] + with pytest.raises(SpekoRouterError) as caught: + await client.close() + + assert caught.value.code == "provider_error" + assert websocket.closed is True diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/uv.lock b/ai_agents/agents/ten_packages/extension/speko_tts2_python/uv.lock new file mode 100644 index 0000000000..70335679c4 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/uv.lock @@ -0,0 +1,238 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "speko-tts2-python" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "websockets", specifier = ">=14.0,<16" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] From 1885bcaa4383e0f6102628cb0c1f8cc35201dac9 Mon Sep 17 00:00:00 2001 From: Abat Date: Fri, 11 Sep 2026 23:15:10 +0500 Subject: [PATCH 02/17] fix: address speko extension review feedback (#2307) --- .../tests/test_connection_status.py | 54 +-- .../tts_guarder/tests/bin/start | 2 +- .../tests/test_connection_status.py | 1 + .../extension/speko_asr_python/README.md | 31 ++ .../extension/speko_asr_python/client.py | 75 +++- .../extension/speko_asr_python/config.py | 3 + .../extension/speko_asr_python/extension.py | 130 ++++++- .../speko_asr_python/tests/conftest.py | 2 + .../speko_asr_python/tests/test_client.py | 131 +++++++ .../speko_asr_python/tests/test_extension.py | 263 +++++++++++++ .../tests/test_package_contract.py | 34 ++ .../extension/speko_tts2_python/README.md | 33 ++ .../extension/speko_tts2_python/client.py | 59 ++- .../extension/speko_tts2_python/config.py | 3 + .../extension/speko_tts2_python/extension.py | 238 ++++++++---- .../speko_tts2_python/tests/conftest.py | 2 + .../speko_tts2_python/tests/test_client.py | 86 +++++ .../speko_tts2_python/tests/test_extension.py | 358 ++++++++++++++++++ .../tests/test_package_contract.py | 34 ++ 19 files changed, 1386 insertions(+), 153 deletions(-) create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py diff --git a/ai_agents/agents/integration_tests/asr_guarder/tests/test_connection_status.py b/ai_agents/agents/integration_tests/asr_guarder/tests/test_connection_status.py index 3eb6e30478..3ca80d7f88 100644 --- a/ai_agents/agents/integration_tests/asr_guarder/tests/test_connection_status.py +++ b/ai_agents/agents/integration_tests/asr_guarder/tests/test_connection_status.py @@ -42,6 +42,7 @@ # Extensions that report connection_status_changed via ten_ai_base. _EXTENSIONS_WITH_CONNECTION_STATUS = frozenset( { + "speko_asr_python", "azure_asr_python", "bytedance_llm_based_asr", "deepgram_asr_python", @@ -78,7 +79,9 @@ def validate_status_payload(payload: dict[str, Any]) -> None: "message", "metadata", ] - missing_fields = [field for field in required_fields if field not in payload] + missing_fields = [ + field for field in required_fields if field not in payload + ] if missing_fields: raise AssertionError( f"connection_status_changed missing fields: {missing_fields}" @@ -133,18 +136,17 @@ def assert_initial_connection_status_sequence( status_events: list[dict[str, Any]], ) -> None: currents = [event["current"] for event in status_events] - assert "connecting" in currents, ( - f"missing connecting status in sequence: {currents}" - ) - assert "connected" in currents, ( - f"missing connected status in sequence: {currents}" - ) + assert ( + "connecting" in currents + ), f"missing connecting status in sequence: {currents}" + assert ( + "connected" in currents + ), f"missing connected status in sequence: {currents}" connecting_index = currents.index("connecting") connected_index = currents.index("connected") assert connecting_index < connected_index, ( - "connecting must appear before connected; " - f"sequence: {currents}" + "connecting must appear before connected; " f"sequence: {currents}" ) assert_valid_connection_transitions(status_events) @@ -160,12 +162,12 @@ def assert_reconnection_status_sequence( currents = [event["current"] for event in status_events] transitions = [(event["last"], event["current"]) for event in status_events] - assert "connecting" in currents, ( - f"missing connecting status in reconnection sequence: {currents}" - ) - assert "disconnected" in currents, ( - f"missing disconnected status in reconnection sequence: {currents}" - ) + assert ( + "connecting" in currents + ), f"missing connecting status in reconnection sequence: {currents}" + assert ( + "disconnected" in currents + ), f"missing disconnected status in reconnection sequence: {currents}" if expect_retry: assert currents.count("connecting") >= 2, ( @@ -177,9 +179,9 @@ def assert_reconnection_status_sequence( f"transitions: {transitions}" ) else: - assert currents.count("connecting") >= 1, ( - f"expected at least one connecting event, got: {currents}" - ) + assert ( + currents.count("connecting") >= 1 + ), f"expected at least one connecting event, got: {currents}" class ConnectionStatusAsrTester(AsyncExtensionTester): @@ -499,9 +501,9 @@ def test_connection_status(extension_name: str, config_dir: str) -> None: tester.set_test_mode_single(extension_name, json.dumps(config)) error = tester.run() - assert error is None, ( - f"Test failed: {error.error_message() if error else 'Unknown error'}" - ) + assert ( + error is None + ), f"Test failed: {error.error_message() if error else 'Unknown error'}" assert_initial_connection_status_sequence(tester.status_events) @@ -527,9 +529,9 @@ def test_connection_status_reconnection( tester.set_test_mode_single(extension_name, json.dumps(config)) error = tester.run() - assert error is None, ( - f"Test failed: {error.error_message() if error else 'Unknown error'}" - ) + assert ( + error is None + ), f"Test failed: {error.error_message() if error else 'Unknown error'}" expect_retry = not tester.fatal_error_received assert_reconnection_status_sequence( @@ -548,9 +550,7 @@ def test_connection_status_reconnection( "Non-fatal errors should trigger retries, but received " f"{tester.errors_received} errors." ) - assert all( - code == non_fatal_code for code in tester.error_codes - ), ( + assert all(code == non_fatal_code for code in tester.error_codes), ( f"All errors should be non-fatal (code={non_fatal_code}), " f"but found unexpected codes: {tester.error_codes}" ) diff --git a/ai_agents/agents/integration_tests/tts_guarder/tests/bin/start b/ai_agents/agents/integration_tests/tts_guarder/tests/bin/start index 84bb6baf11..24baa5d4b7 100755 --- a/ai_agents/agents/integration_tests/tts_guarder/tests/bin/start +++ b/ai_agents/agents/integration_tests/tts_guarder/tests/bin/start @@ -16,7 +16,7 @@ else fi # Only websocket TTS extensions report connection status. -if [[ "${EXTENSION_NAME}" == "fish_audio_tts_python" || "${EXTENSION_NAME}" == "bytedance_tts" || "${EXTENSION_NAME}" == "bytedance_tts_duplex" || "${EXTENSION_NAME}" == "minimax_tts_websocket" || "${EXTENSION_NAME}" == "minimax_tts_websocket_python" || "${EXTENSION_NAME}" == "rime_tts" ]]; then +if [[ "${EXTENSION_NAME}" == "fish_audio_tts_python" || "${EXTENSION_NAME}" == "bytedance_tts" || "${EXTENSION_NAME}" == "bytedance_tts_duplex" || "${EXTENSION_NAME}" == "minimax_tts_websocket" || "${EXTENSION_NAME}" == "minimax_tts_websocket_python" || "${EXTENSION_NAME}" == "rime_tts" || "${EXTENSION_NAME}" == "speko_tts2_python" ]]; then export ENABLE_CONNECTION_STATUS="True" else export ENABLE_CONNECTION_STATUS="False" diff --git a/ai_agents/agents/integration_tests/tts_guarder/tests/test_connection_status.py b/ai_agents/agents/integration_tests/tts_guarder/tests/test_connection_status.py index b47491546c..c7b42bb569 100644 --- a/ai_agents/agents/integration_tests/tts_guarder/tests/test_connection_status.py +++ b/ai_agents/agents/integration_tests/tts_guarder/tests/test_connection_status.py @@ -23,6 +23,7 @@ TTS_CONNECTION_STATUS_CONFIG_FILE = "property_basic_audio_setting1.json" SUPPORTED_WEBSOCKET_TTS_EXTENSIONS = { "fish_audio_tts_python", + "speko_tts2_python", "bytedance_tts", "bytedance_tts_duplex", "minimax_tts_websocket", diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md index 8df8aab132..dd0d85dd14 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -41,3 +41,34 @@ corresponding final transcript. For an explicit route, set `routing` to `{"mode":"explicit","provider":"deepgram","model":"nova-3"}`. + + +## Testing + +From the extension directory, with TEN runtime/base packages installed and +available on `PYTHONPATH`, run the client, extension lifecycle, and package +contract regressions: + +```bash +./tests/bin/start +``` + +From `ai_agents/`, set `SPEKO_API_KEY` and run the live guarder: + +```bash +task asr-guarder-test EXTENSION=speko_asr_python +``` + +Run ASR and TTS guarders sequentially. The guarder uses `tests/configs` and +contacts the hosted Router; it requires a funded key and an available route. +For reproducible provider checks, copy the configs to a temporary directory, +set `params.routing` to an explicit supported provider/model, then invoke the +installed guarder’s `tests/bin/start` with `--extension_name speko_asr_python` +and `--config_dir /absolute/path/to/configs`. Keep invalid-key configs invalid. +Client/lifecycle unit tests use mocked transport and do not require credentials. + +Transient disconnects reconnect on subsequent audio ingress; failed sends are +not replayed. Finalize acknowledges already-finalized input without waiting +for a duplicate transcript. Fallback timestamps retain session position while +resetting per-turn audio accounting. Permanent admission denials require correcting +the cause and restarting the extension. diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py index 683c1a5d2a..12fb68eba3 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py @@ -75,6 +75,8 @@ def __init__( self._ws: ClientConnection | None = None self._listener_task: asyncio.Task[None] | None = None self._finalize_waiter: asyncio.Future[None] | None = None + self._audio_generation = 0 + self._final_generation = -1 self._ready = False self._closing = False @@ -96,6 +98,7 @@ async def connect(self) -> None: self.url, additional_headers=headers, open_timeout=self.ready_timeout_sec, + close_timeout=2.0, ) configure_frame = json.dumps(self.configure, separators=(",", ":")) await self._ws.send(configure_frame) @@ -113,22 +116,34 @@ async def connect(self) -> None: ) self.request_id = str(event.get("request_id", "")) self.route = dict(event.get("route", {})) + self._audio_generation = 0 + self._final_generation = -1 self._ready = True await self.on_event(event) self._listener_task = asyncio.create_task(self._listen()) except InvalidStatus as error: + await self._close_socket() + try: + event = json.loads(error.response.body) + except (ValueError, TypeError): + event = {} + if isinstance(event, dict) and isinstance(event.get("error"), dict): + raise SpekoRouterError.from_event(event) from error status = error.response.status_code - code = ( - "authentication_failed" - if status in (401, 403) - else "relay_error" - ) + code = { + 400: "invalid_request", + 401: "authentication_failed", + 403: "authentication_failed", + 402: "insufficient_credit", + 404: "route_not_found", + 429: "rate_limited", + }.get(status, "relay_error") raise SpekoRouterError( code, f"Speko Router WebSocket upgrade failed ({status})", retryable=status >= 500 or status == 429, ) from error - except Exception: + except (Exception, asyncio.CancelledError): await self._close_socket() raise @@ -137,6 +152,8 @@ async def send_audio(self, audio: bytes) -> None: raise SpekoRouterError( "relay_error", "Speko ASR session is not ready", retryable=True ) + if audio: + self._audio_generation += 1 await self._ws.send(audio) async def commit(self) -> None: @@ -144,16 +161,18 @@ async def commit(self) -> None: raise SpekoRouterError( "relay_error", "Speko ASR session is not ready", retryable=True ) - if self._finalize_waiter and not self._finalize_waiter.done(): - await self._finalize_waiter + if self._final_generation == self._audio_generation: return - - self._finalize_waiter = asyncio.get_running_loop().create_future() - await self._ws.send('{"type":"input.commit"}') + waiter = self._finalize_waiter + owner = waiter is None or waiter.done() + if owner: + waiter = asyncio.get_running_loop().create_future() + self._finalize_waiter = waiter try: + if owner: + await self._ws.send('{"type":"input.commit"}') await asyncio.wait_for( - asyncio.shield(self._finalize_waiter), - timeout=self.finalize_timeout_sec, + asyncio.shield(waiter), timeout=self.finalize_timeout_sec ) except asyncio.TimeoutError as error: raise SpekoRouterError( @@ -161,15 +180,32 @@ async def commit(self) -> None: "Timed out waiting for a final Speko transcript", retryable=True, ) from error + finally: + if owner: + if not waiter.done(): + waiter.cancel() + if self._finalize_waiter is waiter: + self._finalize_waiter = None - async def close(self) -> None: + async def close(self, *, drain: bool = True) -> None: self._closing = True if self._ws is not None and self._ready: try: await self._ws.send('{"type":"session.close"}') except Exception: pass - await self._close_socket() + task = self._listener_task + try: + if ( + drain + and task is not None + and task is not asyncio.current_task() + ): + await asyncio.wait_for(asyncio.shield(task), timeout=2.0) + except asyncio.TimeoutError: + pass + finally: + await self._close_socket() async def _listen(self) -> None: error: SpekoRouterError | None = None @@ -191,10 +227,13 @@ async def _listen(self) -> None: self.usage = dict(event.get("usage", {})) elif event_type == "session.closed": self.usage = dict(event.get("usage", {})) + await self.on_event(event) break + audio_generation = self._audio_generation await self.on_event(event) if event_type == "transcript.final": + self._final_generation = audio_generation waiter = self._finalize_waiter if waiter is not None and not waiter.done(): waiter.set_result(None) @@ -224,12 +263,12 @@ async def _close_socket(self) -> None: if task is not None and task is not asyncio.current_task(): task.cancel() await asyncio.gather(task, return_exceptions=True) - if self._ws is not None: + websocket, self._ws = self._ws, None + if websocket is not None: try: - await self._ws.close() + await websocket.close() except Exception: pass - self._ws = None @staticmethod def _decode_text_event(raw: str | bytes) -> dict[str, Any]: diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py index 9d267c8756..291999eba1 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py @@ -80,6 +80,9 @@ def _validate_routing(self) -> None: raise ValueError("unsupported routing objective") return if mode == "explicit": + # TEN recursively merges property.json defaults into graph params. + # An explicit route must not retain the default auto objective. + self.routing.pop("objective", None) model = str(self.routing.get("model", "")) provider = str(self.routing.get("provider", "")) if not model or (not provider and "/" not in model): diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py index 2f0d70dff7..1828347f69 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -3,6 +3,7 @@ # Licensed under the Apache License, Version 2.0. # See the LICENSE file for more information. # +import asyncio import time import traceback from pathlib import Path @@ -18,6 +19,7 @@ from ten_ai_base.const import LOG_CATEGORY_KEY_POINT, LOG_CATEGORY_VENDOR from ten_ai_base.dumper import Dumper from ten_ai_base.message import ( + ModuleConnectionStatus, ModuleError, ModuleErrorCode, ModuleErrorVendorInfo, @@ -39,6 +41,9 @@ def __init__(self, name: str) -> None: self._connect_started_at = 0.0 self._total_audio_bytes = 0 self._final_cursor_ms = 0 + self._turn_start_ms = 0 + self._permanent_error: SpekoRouterError | None = None + self._reconnect_task: asyncio.Task[None] | None = None self._route: dict[str, Any] = {} @override @@ -50,13 +55,16 @@ def vendor_metadata(self) -> dict[str, Any]: if self.config is None: return {} metadata: dict[str, Any] = { + "key": self.config.api_key, + "api_key": self.config.api_key, + "language": self.config.language, "base_url": self.config.base_url, "routing": self.config.routing, } if self._route: metadata["route"] = self._route metadata["model"] = self._route.get("model", "") - return metadata + return {key: value for key, value in metadata.items() if value} @override async def on_init(self, ten_env: AsyncTenEnv) -> None: @@ -89,6 +97,8 @@ async def on_init(self, ten_env: AsyncTenEnv) -> None: @override async def start_connection(self) -> None: + if self._permanent_error is not None: + return if self.config is None: await self.on_disconnected( code=ModuleErrorCode.FATAL_ERROR.value, @@ -99,6 +109,9 @@ async def start_connection(self) -> None: if self.client is not None: await self.client.close() + self._total_audio_bytes = 0 + self._final_cursor_ms = 0 + self._turn_start_ms = 0 self._connect_started_at = time.monotonic() self.client = SpekoASRClient( api_key=self.config.api_key, @@ -112,6 +125,13 @@ async def start_connection(self) -> None: try: await self.client.connect() except SpekoRouterError as error: + if not error.retryable and error.code in { + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + }: + self._permanent_error = error await self._report_router_error(error) await self.on_disconnected( code=self._module_error_code(error).value, @@ -139,12 +159,26 @@ def is_connected(self) -> bool: @override async def stop_connection(self) -> None: - if self.client is not None: - await self.client.close() - self.client = None - if self.audio_dumper is not None: - await self.audio_dumper.stop() - self.audio_dumper = None + task, self._reconnect_task = self._reconnect_task, None + if task is not None and task is not asyncio.current_task(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + client, self.client = self.client, None + try: + if client is not None: + await client.close() + finally: + dumper, self.audio_dumper = self.audio_dumper, None + if dumper is not None: + await dumper.stop() + + @override + async def on_deinit(self, ten_env: AsyncTenEnv) -> None: + self.stopped = True + try: + await self.stop_connection() + finally: + await super().on_deinit(ten_env) @override def input_audio_sample_rate(self) -> int: @@ -168,6 +202,28 @@ def buffer_strategy(self) -> ASRBufferConfig: byte_limit=bytes_per_ms * self.config.buffer_duration_ms ) + @override + async def on_audio_frame( + self, ten_env: AsyncTenEnv, frame: AudioFrame + ) -> None: + # The base consumer buffers disconnected frames without calling send_audio. + if ( + not self.stopped + and not self.is_connected() + and self.connection_status != ModuleConnectionStatus.CONNECTING + and self._permanent_error is None + and (self.auto_connect or self._connection_requested) + and (self._reconnect_task is None or self._reconnect_task.done()) + ): + self._reconnect_task = asyncio.create_task(self._reconnect()) + await super().on_audio_frame(ten_env, frame) + + async def _reconnect(self) -> None: + await self._ensure_connection() + if not self.is_connected(): + # Coalesce incoming frames during an unsuccessful connection attempt. + await asyncio.sleep(0.5) + @override async def send_audio( self, frame: AudioFrame, session_id: str | None @@ -187,10 +243,12 @@ async def send_audio( await self.audio_dumper.push_bytes(audio) return True except SpekoRouterError as error: - await self._report_router_error(error) + await self._reset_connection(error) return False except Exception as error: - self.ten_env.log_error(f"failed to send audio: {error}") + await self._reset_connection( + SpekoRouterError("relay_error", str(error), retryable=True) + ) return False finally: frame.unlock_buf(buffer) @@ -211,16 +269,51 @@ async def finalize(self, session_id: str | None) -> None: return try: await self.client.commit() - await self.send_asr_finalize_end() + except asyncio.CancelledError: + if not self.stopped: + raise except SpekoRouterError as error: if self.is_connected(): - await self._report_router_error(error) - await self.send_asr_finalize_end() + await self._reset_connection(error) + except Exception as error: + await self._reset_connection( + SpekoRouterError("relay_error", str(error), retryable=True) + ) + finally: + self._turn_start_ms += self._audio_duration_ms( + self._total_audio_bytes + ) + self._total_audio_bytes = 0 + self._final_cursor_ms = self._turn_start_ms + if not self.stopped: + await self.send_asr_finalize_end() + + async def _reset_connection(self, error: SpekoRouterError) -> None: + async with self._connection_lock: + client, self.client = self.client, None + try: + if client is not None: + await client.close() + finally: + await self._on_router_disconnect(error) + # Reconnect on the next audio frame, never replay an ambiguous send. async def _on_router_event(self, event: dict[str, Any]) -> None: event_type = event.get("type") if event_type == "session.ready": self._route = dict(event.get("route", {})) + # Audio may have ended before the handshake completed. The base + # only drains its disconnected buffer when another frame arrives. + # Move it ahead of queued live frames so no extra frame is needed. + if not self.buffered_frames.empty(): + pending = [] + while not self.buffered_frames.empty(): + pending.append(self.buffered_frames.get_nowait()) + while not self.audio_frames_queue.empty(): + pending.append(self.audio_frames_queue.get_nowait()) + self.buffered_frames_size = 0 + for frame in pending: + self.audio_frames_queue.put_nowait(frame) connect_delay = int( (time.monotonic() - self._connect_started_at) * 1000 ) @@ -234,7 +327,7 @@ async def _on_router_event(self, event: dict[str, Any]) -> None: await self._send_transcript(event, final=False) elif event_type == "transcript.final": await self._send_transcript(event, final=True) - elif event_type == "usage.updated": + elif event_type in {"usage.updated", "session.closed"}: usage = dict(event.get("usage", {})) await self.send_vendor_metrics({"usage": usage}) @@ -242,6 +335,13 @@ async def _on_router_disconnect( self, error: SpekoRouterError | None ) -> None: if error is not None: + if not error.retryable and error.code in { + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + }: + self._permanent_error = error await self._report_router_error(error) await self.on_disconnected( code=( @@ -274,7 +374,9 @@ async def _send_transcript( ) else: start_ms = self._final_cursor_ms - end_ms = self._audio_duration_ms(self._total_audio_bytes) + end_ms = self._turn_start_ms + self._audio_duration_ms( + self._total_audio_bytes + ) metadata: dict[str, Any] = { "asr_info": {"vendor": self.vendor(), "locked": False} diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py index 93300358db..df5aba222a 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py @@ -5,3 +5,5 @@ package_name = Path(__file__).parents[1].name addon_name = f"{package_name}.addon" sys.modules.setdefault(addon_name, ModuleType(addon_name)) + +sys.path.insert(0, str(Path(__file__).parents[2])) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py index f472eab98d..2672bd3dc8 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py @@ -151,3 +151,134 @@ def test_websocket_url_preserves_base_path(): assert websocket_url("http://localhost:8080/router/", "/v1/stt/stream") == ( "ws://localhost:8080/router/v1/stt/stream" ) + + +@pytest.mark.asyncio +async def test_timeout_does_not_poison_next_commit(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + client = make_client([], []) + client.finalize_timeout_sec = 0.01 + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + with pytest.raises(SpekoRouterError, match="Timed out"): + await client.commit() + assert client._finalize_waiter is None + next_commit = asyncio.create_task(client.commit()) + await asyncio.sleep(0) + websocket.messages.put_nowait( + json.dumps({"type": "transcript.final", "text": "ok"}) + ) + await asyncio.wait_for(next_commit, 1) + await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_auto_final_before_commit_and_new_audio(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + received = asyncio.Event() + client = make_client([], []) + client.on_event = AsyncMock( + side_effect=lambda event: ( + received.set() if event["type"] == "transcript.final" else None + ) + ) + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + await client.send_audio(b"first") + websocket.messages.put_nowait( + json.dumps({"type": "transcript.final", "text": "first"}) + ) + await asyncio.wait_for(received.wait(), 1) + await client.commit() + assert '{"type":"input.commit"}' not in websocket.sent + await client.send_audio(b"next") + commit = asyncio.create_task(client.commit()) + await asyncio.sleep(0) + assert not commit.done() + websocket.messages.put_nowait( + json.dumps({"type": "transcript.final", "text": "next"}) + ) + await asyncio.wait_for(commit, 1) + await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_close_delivers_terminal_usage(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + events = [] + client = make_client(events, []) + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + websocket.messages.put_nowait( + json.dumps( + {"type": "session.closed", "usage": {"audio_seconds": 2}} + ) + ) + await client.close() + assert events[-1] == { + "type": "session.closed", + "usage": {"audio_seconds": 2}, + } + assert websocket.closed + + +@pytest.mark.asyncio +async def test_failed_commit_send_clears_waiter(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + client = make_client([], []) + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + websocket.send = AsyncMock(side_effect=OSError("socket closed")) + with pytest.raises(OSError): + await client.commit() + assert client._finalize_waiter is None + await client.close(drain=False) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,code,retryable", + [ + (402, "insufficient_credit", False), + (429, "rate_limited", True), + (503, "provider_unavailable", True), + ], +) +async def test_upgrade_preserves_classified_error(status, code, retryable): + from unittest.mock import MagicMock + from websockets.exceptions import InvalidStatus + + response = MagicMock(status_code=status, reason_phrase="denied") + response.body = json.dumps( + { + "error": { + "code": code, + "message": "classified denial", + "retryable": retryable, + } + } + ).encode() + client = make_client([], []) + with patch( + "client.websockets.connect", + AsyncMock(side_effect=InvalidStatus(response)), + ): + with pytest.raises(SpekoRouterError) as caught: + await client.connect() + assert caught.value.code == code + assert caught.value.retryable is retryable + assert not client.is_ready + + +@pytest.mark.asyncio +async def test_cancel_during_handshake_closes_socket(): + websocket = FakeWebSocket() + client = make_client([], []) + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + connecting = asyncio.create_task(client.connect()) + await asyncio.sleep(0) + connecting.cancel() + with pytest.raises(asyncio.CancelledError): + await connecting + assert websocket.closed + assert not client.is_ready diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py new file mode 100644 index 0000000000..4f8fe3127f --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py @@ -0,0 +1,263 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from speko_asr_python.client import SpekoRouterError +from speko_asr_python.config import SpekoASRConfig +from speko_asr_python.extension import SpekoASRExtension + + +def make_extension(): + extension = SpekoASRExtension("speko") + extension.config = SpekoASRConfig(api_key="test-key") + extension.ten_env = MagicMock() + for name in ( + "send_asr_result", + "send_asr_error", + "send_asr_finalize_end", + "on_disconnected", + "send_vendor_metrics", + ): + setattr(extension, name, AsyncMock()) + client = MagicMock(is_ready=True) + client.send_audio = AsyncMock() + client.commit = AsyncMock() + client.close = AsyncMock() + extension.client = client + return extension, client + + +def frame(): + audio = MagicMock() + audio.lock_buf.return_value = bytes(32000) + audio.get_buf.return_value = bytes(32000) + audio.get_property_to_json.return_value = (None, None) + return audio + + +@pytest.mark.asyncio +async def test_multiple_turns_have_separate_duration_and_session_positions(): + extension, _ = make_extension() + for _ in range(2): + assert await extension.send_audio(frame(), "session") + await extension._on_router_event( + {"type": "transcript.final", "text": "hello"} + ) + await extension.finalize("session") + results = [ + call.args[0] for call in extension.send_asr_result.await_args_list + ] + assert [(result.start_ms, result.duration_ms) for result in results] == [ + (0, 1000), + (1000, 1000), + ] + assert extension._total_audio_bytes == 0 + assert extension.send_asr_finalize_end.await_count == 2 + + +@pytest.mark.asyncio +async def test_timeout_disconnects_and_completes_finalize_once(): + extension, client = make_extension() + client.commit.side_effect = SpekoRouterError( + "request_timeout", "timeout", retryable=True + ) + await extension.finalize("session") + client.close.assert_awaited_once() + assert extension.client is None + extension.send_asr_finalize_end.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_failure_recovers_for_next_frame_without_replay(): + extension, client = make_extension() + client.send_audio.side_effect = OSError("closed") + audio = frame() + assert not await extension.send_audio(audio, "session") + audio.unlock_buf.assert_called_once() + client.send_audio.assert_awaited_once() + assert extension.client is None + replacement = MagicMock(is_ready=True, send_audio=AsyncMock()) + + async def reconnect(): + extension.client = replacement + + extension.start_connection = AsyncMock(side_effect=reconnect) + await extension.on_audio_frame(extension.ten_env, frame()) + await extension._reconnect_task + await extension._handle_audio_frame( + extension.ten_env, await extension.audio_frames_queue.get() + ) + extension.start_connection.assert_awaited_once() + replacement.send_audio.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_permanent_denial_does_not_reconnect(): + extension, client = make_extension() + client.send_audio.side_effect = SpekoRouterError( + "insufficient_credit", "no credit" + ) + assert not await extension.send_audio(frame(), "session") + extension.start_connection = AsyncMock() + assert not await extension.send_audio(frame(), "session") + extension.start_connection.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_terminal_usage_is_forwarded(): + extension, _ = make_extension() + await extension._on_router_event( + {"type": "session.closed", "usage": {"audio_seconds": 2}} + ) + extension.send_vendor_metrics.assert_awaited_once_with( + {"usage": {"audio_seconds": 2}} + ) + + +@pytest.mark.asyncio +async def test_deinit_stops_dumper_once_even_after_connection_failure(): + extension, client = make_extension() + dumper = MagicMock(stop=AsyncMock()) + extension.audio_dumper = dumper + client.close.side_effect = OSError("closed") + with patch( + "speko_asr_python.extension.AsyncASRBaseExtension.on_deinit", + new_callable=AsyncMock, + ): + with pytest.raises(OSError): + await extension.on_deinit(extension.ten_env) + await extension.on_deinit(extension.ten_env) + dumper.stop.assert_awaited_once() + assert extension.audio_dumper is None + + +def test_metadata_contains_key_and_language(): + extension, _ = make_extension() + assert extension.vendor_metadata()["key"] == "test-key" + assert extension.vendor_metadata()["api_key"] == "test-key" + assert extension.vendor_metadata()["language"] + + +@pytest.mark.asyncio +async def test_finalize_cancelled_by_shutdown_does_not_emit_after_stop(): + import asyncio + + extension, client = make_extension() + entered = asyncio.Event() + + async def commit(): + entered.set() + await asyncio.Future() + + client.commit.side_effect = commit + task = asyncio.create_task(extension.finalize("session")) + await entered.wait() + extension.stopped = True + task.cancel() + await task + extension.send_asr_finalize_end.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_audio_ingress_does_not_wait_for_reconnection_and_stop_joins_it(): + import asyncio + + extension, _ = make_extension() + extension.client = None + entered = asyncio.Event() + + async def connect(): + entered.set() + await asyncio.Future() + + extension._ensure_connection = AsyncMock(side_effect=connect) + await asyncio.wait_for( + extension.on_audio_frame(extension.ten_env, frame()), 0.1 + ) + await entered.wait() + pending = extension._reconnect_task + await extension.on_audio_frame(extension.ten_env, frame()) + assert extension._reconnect_task is pending + await extension.stop_connection() + assert pending.done() + assert extension._reconnect_task is None + + +def test_explicit_route_removes_merged_auto_objective(): + config = SpekoASRConfig( + api_key="test-key", + params={ + "routing": { + "mode": "explicit", + "objective": "balanced", + "provider": "deepgram", + "model": "nova-3", + } + }, + ) + config.update_params() + assert config.routing == { + "mode": "explicit", + "provider": "deepgram", + "model": "nova-3", + } + + +@pytest.mark.asyncio +async def test_connection_ready_requeues_early_audio_before_live_frames(): + extension, client = make_extension() + extension.on_connected = AsyncMock() + extension.send_connect_delay_metrics = AsyncMock() + client.is_ready = False + early = frame() + await extension._handle_audio_frame(extension.ten_env, early) + live = frame() + extension.audio_frames_queue.put_nowait(live) + client.is_ready = True + await extension._on_router_event({"type": "session.ready", "route": {}}) + assert extension.buffered_frames.empty() + assert extension.buffered_frames_size == 0 + assert extension.audio_frames_queue.get_nowait() is early + assert extension.audio_frames_queue.get_nowait() is live + + +@pytest.mark.asyncio +async def test_audio_during_initial_connection_does_not_schedule_retry(): + extension, _ = make_extension() + extension.client = None + extension._connection_machine.try_connecting() + await extension.on_audio_frame(extension.ten_env, frame()) + assert extension._reconnect_task is None + assert extension.audio_frames_queue.qsize() == 1 + + +@pytest.mark.asyncio +async def test_reconnect_waits_for_failed_session_cleanup(): + import asyncio + + extension, client = make_extension() + entered, release = asyncio.Event(), asyncio.Event() + + async def close(): + entered.set() + await release.wait() + + client.close.side_effect = close + resetting = asyncio.create_task( + extension._reset_connection( + SpekoRouterError("relay_error", "broken", retryable=True) + ) + ) + await entered.wait() + + async def reconnect(): + extension.client = MagicMock(is_ready=True) + + extension.start_connection = AsyncMock(side_effect=reconnect) + await extension.on_audio_frame(extension.ten_env, frame()) + await asyncio.sleep(0) + extension.start_connection.assert_not_awaited() + release.set() + await resetting + await extension._reconnect_task + extension.start_connection.assert_awaited_once() diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py new file mode 100644 index 0000000000..2d5f981b50 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py @@ -0,0 +1,34 @@ +import json +from pathlib import Path + +import pytest + +PACKAGE_ROOT = Path(__file__).parents[1] + + +def test_manifest_defaults_and_registration(): + manifest = json.loads((PACKAGE_ROOT / "manifest.json").read_text()) + defaults = json.loads((PACKAGE_ROOT / "property.json").read_text()) + properties = manifest["api"]["property"]["properties"] + assert manifest["name"] == PACKAGE_ROOT.name + assert f'"{PACKAGE_ROOT.name}"' in (PACKAGE_ROOT / "addon.py").read_text() + assert set(defaults) <= set(properties) + assert set(defaults["params"]) <= set(properties["params"]["properties"]) + interface = "asr" if "asr" in PACKAGE_ROOT.name else "tts" + assert manifest["api"]["interface"][0]["import_uri"].endswith( + f"/{interface}-interface.json" + ) + + +@pytest.mark.parametrize( + "config_path", + sorted((PACKAGE_ROOT / "tests/configs").glob("*.json")), + ids=lambda p: p.name, +) +def test_guarder_config_properties_are_declared(config_path): + config = json.loads(config_path.read_text()) + manifest = json.loads((PACKAGE_ROOT / "manifest.json").read_text()) + properties = manifest["api"]["property"]["properties"] + assert set(config) <= set(properties) + assert "params" in config + assert set(config["params"]) <= set(properties["params"]["properties"]) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md index cdda53b4c8..aa9fad3b75 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md @@ -37,3 +37,36 @@ Output is raw signed 16-bit little-endian PCM. TEN `tts_flush` maps to Speko } } ``` + + +## Testing + +From the extension directory, with TEN runtime/base packages installed and +available on `PYTHONPATH`, run the client, extension lifecycle, and package +contract regressions: + +```bash +./tests/bin/start +``` + +From `ai_agents/`, set `SPEKO_API_KEY` and run the live guarder: + +```bash +task tts-guarder-test EXTENSION=speko_tts2_python +``` + +Run ASR and TTS guarders sequentially. The guarder uses `tests/configs` and +contacts the hosted Router; it requires a funded key and an available route. +For reproducible provider checks, copy the configs to a temporary directory, +set `params.routing` to an explicit supported provider/model, then invoke the +installed guarder’s `EXT_NAME=speko_tts2_python tests/bin/start` with +`--extension_name speko_tts2_python` +and `--config_dir /absolute/path/to/configs`. Keep invalid-key configs invalid. +Client/lifecycle unit tests use mocked transport and do not require credentials. + +Text chunks retain their whitespace. An intermediate-chunk error resets the +connection while retaining the TEN request until its final input. Failed text +is never replayed; later text can continue unless a permanent admission denial +blocks the session. +A terminal close error produces an error completion and still releases the +request. Non-retryable denials are not automatically retried. diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py index 14e97f4db3..afa404f2a7 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py @@ -10,7 +10,7 @@ import websockets from websockets.asyncio.client import ClientConnection -from websockets.exceptions import InvalidStatus +from websockets.exceptions import ConnectionClosed, InvalidStatus class SpekoRouterError(Exception): @@ -80,6 +80,7 @@ def __init__( self.usage: dict[str, Any] = {} self._ws: ClientConnection | None = None self._ready = False + self._cancelled = False @property def is_ready(self) -> bool: @@ -88,6 +89,7 @@ def is_ready(self) -> bool: async def connect(self) -> None: if self.is_ready: return + self._cancelled = False headers = { "Authorization": f"Bearer {self.api_key}", "Idempotency-Key": uuid.uuid4().hex, @@ -97,6 +99,7 @@ async def connect(self) -> None: self.url, additional_headers=headers, open_timeout=self.ready_timeout_sec, + close_timeout=2.0, ) await self._ws.send( json.dumps(self.configure, separators=(",", ":")) @@ -117,19 +120,28 @@ async def connect(self) -> None: self.route = dict(event.get("route", {})) self._ready = True except InvalidStatus as error: - status = error.response.status_code - code = ( - "authentication_failed" - if status in (401, 403) - else "relay_error" - ) await self._close_socket() + try: + event = json.loads(error.response.body) + except (ValueError, TypeError): + event = {} + if isinstance(event, dict) and isinstance(event.get("error"), dict): + raise SpekoRouterError.from_event(event) from error + status = error.response.status_code + code = { + 400: "invalid_request", + 401: "authentication_failed", + 403: "authentication_failed", + 402: "insufficient_credit", + 404: "route_not_found", + 429: "rate_limited", + }.get(status, "relay_error") raise SpekoRouterError( code, f"Speko Router WebSocket upgrade failed ({status})", retryable=status >= 500 or status == 429, ) from error - except Exception: + except (Exception, asyncio.CancelledError): await self._close_socket() raise @@ -139,22 +151,27 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: "relay_error", "Speko TTS session is not ready", retryable=True ) - await self._ws.send( + websocket = self._ws + await websocket.send( json.dumps( {"type": "input.append", "text": text}, separators=(",", ":"), ) ) - await self._ws.send('{"type":"input.commit"}') + await websocket.send('{"type":"input.commit"}') sent_at = time.monotonic() sequence: int | None = None first_audio = True - while True: + while not self._cancelled: try: raw = await asyncio.wait_for( - self._ws.recv(), timeout=self.receive_timeout_sec + websocket.recv(), timeout=self.receive_timeout_sec ) + except ConnectionClosed: + if self._cancelled: + return + raise except asyncio.TimeoutError as error: raise SpekoRouterError( "request_timeout", @@ -162,6 +179,8 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: retryable=True, ) from error + if self._cancelled: + return if isinstance(raw, bytes): if sequence is None: raise SpekoRouterError( @@ -175,6 +194,8 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: SpekoTTSEventType.TTFB, int((time.monotonic() - sent_at) * 1000), ) + if self._cancelled: + return yield SpekoTTSEvent(SpekoTTSEventType.AUDIO, raw) continue @@ -207,6 +228,7 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: ) async def cancel(self) -> None: + self._cancelled = True if self._ws is not None and self._ready: try: await self._ws.send('{"type":"input.cancel"}') @@ -227,10 +249,11 @@ async def close(self, *, drain: bool = True) -> None: await self._close_socket() async def _drain_close(self) -> None: - assert self._ws is not None + websocket = self._ws + assert websocket is not None try: - while True: - raw = await asyncio.wait_for(self._ws.recv(), timeout=2.0) + while not self._cancelled: + raw = await asyncio.wait_for(websocket.recv(), timeout=2.0) if isinstance(raw, bytes): continue event = self._decode_event(raw) @@ -246,12 +269,12 @@ async def _drain_close(self) -> None: async def _close_socket(self) -> None: self._ready = False - if self._ws is not None: + websocket, self._ws = self._ws, None + if websocket is not None: try: - await self._ws.close() + await websocket.close() except Exception: pass - self._ws = None @staticmethod def _decode_event(raw: str | bytes) -> dict[str, Any]: diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py index 914eac9f53..2b5a47d6bd 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py @@ -75,6 +75,9 @@ def _validate_routing(self) -> None: raise ValueError("unsupported routing objective") return if mode == "explicit": + # TEN recursively merges property.json defaults into graph params. + # An explicit route must not retain the default auto objective. + self.routing.pop("objective", None) model = str(self.routing.get("model", "")) provider = str(self.routing.get("provider", "")) if not model or (not provider and "/" not in model): diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py index 5abbe734b9..f09eba19d1 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py @@ -3,9 +3,9 @@ # Licensed under the Apache License, Version 2.0. # See the LICENSE file for more information. # +import asyncio import os import time -import traceback from typing import Any from ten_ai_base.const import LOG_CATEGORY_KEY_POINT, LOG_CATEGORY_VENDOR @@ -41,7 +41,10 @@ def __init__(self, name: str) -> None: self._first_audio_at: float | None = None self._total_audio_bytes = 0 self._finalized = False + self._audio_end_sent = False + self._cancelled = False self._stopped = False + self._permanent_error: SpekoRouterError | None = None self._recorders: dict[str, PCMWriter] = {} async def on_init(self, ten_env: AsyncTenEnv) -> None: @@ -74,11 +77,18 @@ async def on_init(self, ten_env: AsyncTenEnv) -> None: async def on_stop(self, ten_env: AsyncTenEnv) -> None: self._stopped = True - await self._close_client() - for recorder in self._recorders.values(): - await recorder.flush() - self._recorders.clear() - await super().on_stop(ten_env) + task = self.current_task + await self._cancel_current_task() + if task is not None and task is not asyncio.current_task(): + await asyncio.gather(task, return_exceptions=True) + try: + await self._close_client() + finally: + try: + for request_id in list(self._recorders): + await self._flush_recorder(request_id) + finally: + await super().on_stop(ten_env) def vendor(self) -> str: return "speko" @@ -87,13 +97,16 @@ def vendor_metadata(self) -> dict[str, Any]: if self.config is None: return {} metadata: dict[str, Any] = { + "key": self.config.api_key, + "api_key": self.config.api_key, + "language": self.config.language, "base_url": self.config.base_url, "routing": self.config.routing, } if self._route: metadata["route"] = self._route metadata["model"] = self._route.get("model", "") - return metadata + return {key: value for key, value in metadata.items() if value} def synthesize_audio_sample_rate(self) -> int: return self.config.sample_rate if self.config else 24000 @@ -102,6 +115,17 @@ def synthesize_audio_channels(self) -> int: return self.config.channels if self.config else 1 async def request_tts(self, text_input: TTSTextInput) -> None: + # TEN flush cancels current_task; keep network work in that child task + # so interruption cannot leave a stream reading a closed client. + task = asyncio.create_task(self._request_tts(text_input)) + self.current_task = task + try: + await task + finally: + if self.current_task is task: + self.current_task = None + + async def _request_tts(self, text_input: TTSTextInput) -> None: if self.config is None or self._stopped: return @@ -114,50 +138,62 @@ async def request_tts(self, text_input: TTSTextInput) -> None: ) return - text = text_input.text.strip() + if self._permanent_error is not None: + raise self._permanent_error + text = text_input.text if text: await self._stream_text(text, text_input.request_id) if text_input.text_input_end: await self._finalize_request(TTSAudioEndReason.REQUEST_END) except SpekoRouterError as error: - await self._handle_router_error(error) + if self._cancelled or self._stopped: + return + await self._handle_router_error(error, text_input.text_input_end) except Exception as error: - self.ten_env.log_error( - f"Speko TTS request failed: {traceback.format_exc()}" - ) - await self.on_disconnected( - code=ModuleErrorCode.NON_FATAL_ERROR.value, - message=str(error), - vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), - ) - await self._finalize_request( - TTSAudioEndReason.ERROR, - error=ModuleError( - module=ModuleType.TTS, - code=ModuleErrorCode.NON_FATAL_ERROR.value, - message=str(error), - vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), - ), + if self._cancelled or self._stopped: + return + await self._handle_router_error( + SpekoRouterError("relay_error", str(error), retryable=True), + text_input.text_input_end, ) async def cancel_tts(self) -> None: request_id = self.current_request_id - if request_id is None or self._finalized: + if request_id is None or self._audio_end_sent or self._cancelled: return self._finalized = True - if self.client is not None: - await self.client.cancel() - self.client = None - await self.on_disconnected(code=0, message="cancelled") - await self._ensure_audio_start() - await self.send_tts_audio_end( - request_id=request_id, - request_event_interval_ms=self._request_interval_ms(), - request_total_audio_duration_ms=self._audio_duration_ms(), - reason=TTSAudioEndReason.INTERRUPTED, - ) - await self._flush_recorder(request_id) + self._cancelled = True + client, self.client = self.client, None + try: + if client is not None: + await client.cancel() + except asyncio.CancelledError: + if not self._stopped: + raise + except Exception as error: + router_error = ( + error + if isinstance(error, SpekoRouterError) + else SpekoRouterError("relay_error", str(error), retryable=True) + ) + await self.send_tts_error( + request_id, self._make_module_error(router_error) + ) + finally: + try: + if not self._stopped: + await self.on_disconnected(code=0, message="cancelled") + await self._ensure_audio_start() + self._audio_end_sent = True + await self.send_tts_audio_end( + request_id=request_id, + request_event_interval_ms=self._request_interval_ms(), + request_total_audio_duration_ms=self._audio_duration_ms(), + reason=TTSAudioEndReason.INTERRUPTED, + ) + finally: + await self._flush_recorder(request_id) async def _begin_request(self, text_input: TTSTextInput) -> None: if self.client is not None: @@ -169,6 +205,8 @@ async def _begin_request(self, text_input: TTSTextInput) -> None: self._first_audio_at = None self._total_audio_bytes = 0 self._finalized = False + self._audio_end_sent = False + self._cancelled = False await self._setup_recorder(text_input.request_id) async def _ensure_client(self) -> SpekoTTSClient: @@ -203,8 +241,10 @@ async def _ensure_client(self) -> SpekoTTSClient: async def _stream_text(self, text: str, request_id: str) -> None: client = await self._ensure_client() - self.metrics_add_input_characters(len(text)) + self.metrics_add_output_characters(len(text)) async for event in client.stream_text(text): + if self._cancelled or self._stopped: + return if event.type == SpekoTTSEventType.TTFB: if not self._audio_start_sent: await self._ensure_audio_start() @@ -237,50 +277,96 @@ async def _finalize_request( if request_id is None or self._finalized: return self._finalized = True - await self._ensure_audio_start() - await self.send_tts_audio_end( - request_id=request_id, - request_event_interval_ms=self._request_interval_ms(), - request_total_audio_duration_ms=self._audio_duration_ms(), - reason=reason, - extra_metadata={"router_usage": self._router_usage}, - ) - await self._close_client() - await self.send_usage_metrics( - request_id, - extra_metadata={"router_usage": self._router_usage}, - ) - await self._flush_recorder(request_id) - await self.finish_request(request_id, reason=reason, error=error) + cancelled = False + try: + try: + await self._close_client() + except Exception as close_error: + if self._cancelled or self._stopped: + return + router_error = ( + close_error + if isinstance(close_error, SpekoRouterError) + else SpekoRouterError( + "relay_error", str(close_error), retryable=True + ) + ) + reason = TTSAudioEndReason.ERROR + error = self._make_module_error(router_error) + await self.on_disconnected( + code=error.code, + message=error.message, + vendor_info=error.vendor_info, + ) + if self._cancelled: + return + await self._ensure_audio_start() + self._audio_end_sent = True + await self.send_tts_audio_end( + request_id=request_id, + request_event_interval_ms=self._request_interval_ms(), + request_total_audio_duration_ms=self._audio_duration_ms(), + reason=reason, + extra_metadata={"router_usage": self._router_usage}, + ) + await self.send_usage_metrics( + request_id, + extra_metadata={"router_usage": self._router_usage}, + ) + except asyncio.CancelledError: + cancelled = True + raise + finally: + # Flush owns interruption state; do not finish a cancelled request + # as a successful request while cancel_tts is emitting its end. + if not cancelled and not self._cancelled: + try: + await self._flush_recorder(request_id) + finally: + await self.finish_request( + request_id, reason=reason, error=error + ) - async def _handle_router_error(self, error: SpekoRouterError) -> None: - self.ten_env.log_error( - f"vendor_error: code={error.code}, message={error.message}", - category=LOG_CATEGORY_VENDOR, - ) - module_code = self._module_error_code(error) - module_error = ModuleError( + def _make_module_error(self, error: SpekoRouterError) -> ModuleError: + if not error.retryable and error.code in { + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + }: + self._permanent_error = error + return ModuleError( module=ModuleType.TTS, - code=module_code.value, + code=self._module_error_code(error).value, message=error.message, vendor_info=ModuleErrorVendorInfo( - vendor=self.vendor(), - code=error.code, - message=error.message, + vendor=self.vendor(), code=error.code, message=error.message ), ) + + async def _handle_router_error( + self, error: SpekoRouterError, final_input: bool + ) -> None: + module_error = self._make_module_error(error) await self.on_disconnected( - code=module_code.value, + code=module_error.code, message=error.message, vendor_info=module_error.vendor_info, ) if self._finalized: await self.send_tts_error(self.current_request_id, module_error) - return - await self._finalize_request( - TTSAudioEndReason.ERROR, - error=module_error, - ) + elif final_input: + await self._finalize_request( + TTSAudioEndReason.ERROR, error=module_error + ) + else: + # The remaining chunks still belong to the same TEN request. + client, self.client = self.client, None + try: + if client is not None: + await client.close(drain=False) + finally: + await self.send_tts_error(self.current_request_id, module_error) async def _ensure_audio_start(self) -> None: if self._audio_start_sent or self.current_request_id is None: @@ -294,13 +380,15 @@ async def _ensure_audio_start(self) -> None: async def _close_client(self) -> None: if self.client is None: return - client = self.client + client, self.client = self.client, None + request_id = self.current_request_id try: await client.close() finally: - self._router_usage = client.usage or self._router_usage - self.client = None - await self.on_disconnected(code=0, message="closed") + if self.current_request_id == request_id: + self._router_usage = client.usage or self._router_usage + if self.current_request_id == request_id and self.client is None: + await self.on_disconnected(code=0, message="closed") def _configure_frame(self) -> dict[str, Any]: assert self.config is not None diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py index 93300358db..df5aba222a 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py @@ -5,3 +5,5 @@ package_name = Path(__file__).parents[1].name addon_name = f"{package_name}.addon" sys.modules.setdefault(addon_name, ModuleType(addon_name)) + +sys.path.insert(0, str(Path(__file__).parents[2])) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py index d3bdda0d5f..e25056b649 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py @@ -193,3 +193,89 @@ async def test_close_surfaces_terminal_router_error(): assert caught.value.code == "provider_error" assert websocket.closed is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,code,retryable", + [ + (402, "insufficient_credit", False), + (429, "rate_limited", True), + (503, "provider_unavailable", True), + ], +) +async def test_upgrade_preserves_classified_error(status, code, retryable): + from unittest.mock import MagicMock + from websockets.exceptions import InvalidStatus + + response = MagicMock(status_code=status, reason_phrase="denied") + response.body = json.dumps( + { + "error": { + "code": code, + "message": "classified denial", + "retryable": retryable, + } + } + ).encode() + client = make_client() + with patch( + "client.websockets.connect", + AsyncMock(side_effect=InvalidStatus(response)), + ): + with pytest.raises(SpekoRouterError) as caught: + await client.connect() + assert caught.value.code == code + assert caught.value.retryable is retryable + assert not client.is_ready + + +@pytest.mark.asyncio +async def test_cancel_during_handshake_closes_socket(): + websocket = FakeWebSocket() + client = make_client() + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + connecting = asyncio.create_task(client.connect()) + await asyncio.sleep(0) + connecting.cancel() + with pytest.raises(asyncio.CancelledError): + await connecting + assert websocket.closed + assert not client.is_ready + + +@pytest.mark.asyncio +@pytest.mark.parametrize("consume_audio", [False, True]) +async def test_cancel_stops_buffered_audio_without_reading_closed_socket( + consume_audio, +): + websocket = FakeWebSocket( + json.dumps({"type": "session.ready"}), + json.dumps({"type": "utterance.started", "sequence": 1}), + b"audio", + ) + client = make_client() + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + stream = client.stream_text("hello") + assert (await anext(stream)).type == SpekoTTSEventType.TTFB + if consume_audio: + assert (await anext(stream)).type == SpekoTTSEventType.AUDIO + await client.cancel() + assert [event async for event in stream] == [] + assert websocket.closed + + +@pytest.mark.asyncio +async def test_cancel_discards_a_receive_completing_during_close(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + client = make_client() + with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + await client.connect() + stream = client.stream_text("hello") + receiving = asyncio.create_task(anext(stream)) + await asyncio.sleep(0) + await client.cancel() + websocket.messages.put_nowait(b"late audio") + with pytest.raises(StopAsyncIteration): + await receiving diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py new file mode 100644 index 0000000000..da01f5bc3e --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py @@ -0,0 +1,358 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from ten_ai_base.message import TTSAudioEndReason +from ten_ai_base.struct import TTSTextInput + +from speko_tts2_python.client import SpekoRouterError +from speko_tts2_python.config import SpekoTTS2Config +from speko_tts2_python.extension import SpekoTTS2Extension + + +def make_extension(): + extension = SpekoTTS2Extension("speko") + extension.config = SpekoTTS2Config(api_key="test-key") + extension.ten_env = MagicMock() + for name in ( + "on_disconnected", + "send_tts_audio_start", + "send_tts_audio_end", + "send_tts_error", + "send_usage_metrics", + "_flush_recorder", + ): + setattr(extension, name, AsyncMock()) + # Wrap the actual base method to verify it releases the request slot. + extension.finish_request = AsyncMock(wraps=extension.finish_request) + return extension + + +def text(value="hello", request_id="req", final=False): + return TTSTextInput(text=value, request_id=request_id, text_input_end=final) + + +@pytest.mark.asyncio +async def test_intermediate_error_keeps_request_open_and_next_chunk_runs(): + extension = make_extension() + extension._processing_request_id = "req" + client = MagicMock() + client.close = AsyncMock() + + async def fail_once(*_): + extension.client = client + raise SpekoRouterError("provider_error", "temporary", retryable=True) + + extension._stream_text = AsyncMock(side_effect=fail_once) + await extension.request_tts(text()) + extension.finish_request.assert_not_awaited() + extension.send_tts_audio_end.assert_not_awaited() + assert extension._processing_request_id == "req" + client.close.assert_awaited_once_with(drain=False) + extension._stream_text.side_effect = None + await extension.request_tts(text(" next", final=True)) + extension._stream_text.assert_awaited_with(" next", "req") + extension.finish_request.assert_awaited_once() + assert extension._processing_request_id is None + + +@pytest.mark.asyncio +async def test_close_error_emits_error_end_and_releases_next_request(): + extension = make_extension() + await extension._begin_request(text()) + extension._processing_request_id = "req" + client = MagicMock(usage={"characters": 5}) + client.close = AsyncMock( + side_effect=SpekoRouterError( + "provider_error", "settlement failed", retryable=True + ) + ) + extension.client = client + await extension._finalize_request(TTSAudioEndReason.REQUEST_END) + extension.send_tts_audio_end.assert_awaited_once() + assert ( + extension.send_tts_audio_end.await_args.kwargs["reason"] + == TTSAudioEndReason.ERROR + ) + assert extension.send_usage_metrics.await_args.kwargs["extra_metadata"][ + "router_usage" + ] == {"characters": 5} + extension._flush_recorder.assert_awaited_once_with("req") + extension.finish_request.assert_awaited_once() + assert extension._processing_request_id is None + assert extension.client is None + await extension._finalize_request(TTSAudioEndReason.REQUEST_END) + extension.finish_request.assert_awaited_once() + extension._stream_text = AsyncMock() + await extension.request_tts(text("next", "next", True)) + assert extension.finish_request.await_count == 2 + + +@pytest.mark.asyncio +async def test_final_chunk_error_finishes_once(): + extension = make_extension() + extension._stream_text = AsyncMock(side_effect=OSError("closed")) + await extension.request_tts(text(final=True)) + extension.finish_request.assert_awaited_once() + assert ( + extension.finish_request.await_args.kwargs["reason"] + == TTSAudioEndReason.ERROR + ) + + +@pytest.mark.asyncio +async def test_whitespace_and_output_metrics(): + extension = make_extension() + client = MagicMock() + + async def stream(value): + sent.append(value) + if False: + yield + + sent = [] + client.stream_text = stream + extension._ensure_client = AsyncMock(return_value=client) + extension.metrics_add_output_characters = MagicMock() + extension.metrics_add_input_characters = MagicMock() + for value in ("hello", " ", " world "): + await extension.request_tts(text(value)) + assert sent == ["hello", " ", " world "] + assert ( + sum( + call.args[0] + for call in extension.metrics_add_output_characters.call_args_list + ) + == 13 + ) + extension.metrics_add_input_characters.assert_not_called() + + +@pytest.mark.asyncio +async def test_permanent_denial_does_not_reopen_client(): + extension = make_extension() + extension._stream_text = AsyncMock( + side_effect=SpekoRouterError("insufficient_credit", "no credit") + ) + await extension.request_tts(text()) + await extension.request_tts(text("next", final=True)) + assert extension._stream_text.await_count == 1 + extension.finish_request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cancel_cleanup_even_if_client_fails(): + extension = make_extension() + await extension._begin_request(text()) + client = MagicMock() + client.cancel = AsyncMock(side_effect=OSError("closed")) + extension.client = client + await extension.cancel_tts() + assert extension.client is None + extension._flush_recorder.assert_awaited_once_with("req") + assert ( + extension.send_tts_audio_end.await_args.kwargs["reason"] + == TTSAudioEndReason.INTERRUPTED + ) + extension.finish_request.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_recorder_failure_still_releases_request(): + extension = make_extension() + await extension._begin_request(text()) + extension._processing_request_id = "req" + extension._flush_recorder.side_effect = OSError("disk") + with pytest.raises(OSError): + await extension._finalize_request(TTSAudioEndReason.REQUEST_END) + extension.finish_request.assert_awaited_once() + assert extension._processing_request_id is None + + +def test_vendor_metadata(): + extension = make_extension() + assert extension.vendor_metadata()["key"] == "test-key" + assert extension.vendor_metadata()["api_key"] == "test-key" + assert extension.vendor_metadata()["language"] == "en" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "code", ["provider_error", "relay_error", "invalid_request"] +) +async def test_nonretryable_chunk_error_allows_new_text_without_replay(code): + extension = make_extension() + extension._stream_text = AsyncMock( + side_effect=SpekoRouterError(code, "chunk failed", retryable=False) + ) + await extension.request_tts(text(" ")) + extension.finish_request.assert_not_awaited() + extension._stream_text.side_effect = None + await extension.request_tts(text("valid next chunk", final=True)) + assert [ + call.args[0] for call in extension._stream_text.await_args_list + ] == [" ", "valid next chunk"] + extension.send_tts_error.assert_awaited_once() + await extension.request_tts(text("new", "next", True)) + extension._stream_text.assert_awaited_with("new", "next") + assert extension.finish_request.await_count == 2 + + +@pytest.mark.asyncio +async def test_flush_during_close_emits_only_interruption(): + import asyncio + + extension = make_extension() + await extension._begin_request(text()) + entered = asyncio.Event() + + async def close(): + entered.set() + await asyncio.Future() + + client = MagicMock(usage={}) + client.close = AsyncMock(side_effect=close) + client.cancel = AsyncMock() + extension.client = client + finalizing = asyncio.create_task( + extension._finalize_request(TTSAudioEndReason.REQUEST_END) + ) + await entered.wait() + finalizing.cancel() + await extension.cancel_tts() + with pytest.raises(asyncio.CancelledError): + await finalizing + extension.send_tts_audio_end.assert_awaited_once() + assert ( + extension.send_tts_audio_end.await_args.kwargs["reason"] + == TTSAudioEndReason.INTERRUPTED + ) + extension._flush_recorder.assert_awaited_once_with("req") + extension.finish_request.assert_not_awaited() + + +def test_explicit_route_removes_merged_auto_objective(): + config = SpekoTTS2Config( + api_key="test-key", + params={ + "routing": { + "mode": "explicit", + "objective": "balanced", + "provider": "deepgram", + "model": "nova-3", + } + }, + ) + config.update_params() + assert config.routing == { + "mode": "explicit", + "provider": "deepgram", + "model": "nova-3", + } + + +@pytest.mark.asyncio +async def test_shutdown_joins_reentrant_cancel_without_duplicate_end(): + import asyncio + + extension = make_extension() + await extension._begin_request(text()) + entered = asyncio.Event() + + async def cancel(): + entered.set() + await asyncio.Future() + + client = MagicMock(cancel=AsyncMock(side_effect=cancel)) + extension.client = client + cancelling = asyncio.create_task(extension.cancel_tts()) + await entered.wait() + extension._stopped = True + await extension.cancel_tts() + cancelling.cancel() + await cancelling + client.cancel.assert_awaited_once() + extension._flush_recorder.assert_awaited_once_with("req") + extension.send_tts_audio_end.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_old_close_does_not_clear_a_new_requests_client_or_usage(): + import asyncio + + extension = make_extension() + extension.current_request_id = "old" + entered, release = asyncio.Event(), asyncio.Event() + + async def close(): + entered.set() + await release.wait() + + extension.client = MagicMock( + close=AsyncMock(side_effect=close), usage={"characters": 1} + ) + closing = asyncio.create_task(extension._close_client()) + await entered.wait() + replacement = MagicMock() + extension.client = replacement + extension.current_request_id = "new" + extension._router_usage = {"characters": 2} + release.set() + await closing + assert extension.client is replacement + assert extension._router_usage == {"characters": 2} + extension.on_disconnected.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("late_error", [False, True]) +async def test_base_flush_cancels_active_stream_and_allows_next_request( + late_error, +): + import asyncio + from ten_ai_base.tts2 import RequestState + + extension = make_extension() + extension._processing_request_id = "req" + extension.request_states["req"] = RequestState.PROCESSING + started = asyncio.Event() + cancelled = asyncio.Event() + client = MagicMock() + client.cancel = AsyncMock() + + async def stream(*_): + extension.client = client + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + if late_error: + # A receive can complete concurrently with task cancellation. + raise OSError("socket closed during flush") from None + raise + finally: + cancelled.set() + + extension._stream_text = AsyncMock(side_effect=stream) + request = asyncio.create_task(extension.request_tts(text())) + await started.wait() + await extension._flush_input_items() + if late_error: + await request + else: + with pytest.raises(asyncio.CancelledError): + await request + assert cancelled.is_set() + assert extension.current_task is None + assert extension._processing_request_id is None + extension.send_tts_error.assert_not_awaited() + extension.finish_request.assert_not_awaited() + extension.send_tts_audio_end.assert_awaited_once() + assert ( + extension.send_tts_audio_end.await_args.kwargs["reason"] + == TTSAudioEndReason.INTERRUPTED + ) + client.cancel.assert_awaited_once() + + extension._stream_text.side_effect = None + await extension.request_tts(text("next", "next", True)) + extension.finish_request.assert_awaited_once() diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py new file mode 100644 index 0000000000..2d5f981b50 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py @@ -0,0 +1,34 @@ +import json +from pathlib import Path + +import pytest + +PACKAGE_ROOT = Path(__file__).parents[1] + + +def test_manifest_defaults_and_registration(): + manifest = json.loads((PACKAGE_ROOT / "manifest.json").read_text()) + defaults = json.loads((PACKAGE_ROOT / "property.json").read_text()) + properties = manifest["api"]["property"]["properties"] + assert manifest["name"] == PACKAGE_ROOT.name + assert f'"{PACKAGE_ROOT.name}"' in (PACKAGE_ROOT / "addon.py").read_text() + assert set(defaults) <= set(properties) + assert set(defaults["params"]) <= set(properties["params"]["properties"]) + interface = "asr" if "asr" in PACKAGE_ROOT.name else "tts" + assert manifest["api"]["interface"][0]["import_uri"].endswith( + f"/{interface}-interface.json" + ) + + +@pytest.mark.parametrize( + "config_path", + sorted((PACKAGE_ROOT / "tests/configs").glob("*.json")), + ids=lambda p: p.name, +) +def test_guarder_config_properties_are_declared(config_path): + config = json.loads(config_path.read_text()) + manifest = json.loads((PACKAGE_ROOT / "manifest.json").read_text()) + properties = manifest["api"]["property"]["properties"] + assert set(config) <= set(properties) + assert "params" in config + assert set(config["params"]) <= set(properties["params"]["properties"]) From a27db61878ad9e557439add01a70dc4b0fceaf33 Mon Sep 17 00:00:00 2001 From: Abat Date: Fri, 18 Sep 2026 13:45:09 +0500 Subject: [PATCH 03/17] fix: harden speko lifecycle and review validation (#2307) --- .../tests/test_connection_status.py | 55 +- .../extension/speko_asr_python/README.md | 36 +- .../extension/speko_asr_python/client.py | 6 +- .../extension/speko_asr_python/config.py | 79 ++- .../extension/speko_asr_python/extension.py | 660 ++++++++++++------ .../extension/speko_asr_python/pyproject.toml | 3 + .../speko_asr_python/tests/bin/start | 7 +- .../speko_asr_python/tests/conftest.py | 5 - .../speko_asr_python/tests/requirements.txt | 3 + .../speko_asr_python/tests/test_client.py | 57 +- .../speko_asr_python/tests/test_extension.py | 18 +- .../tests/test_package_contract.py | 8 + .../speko_asr_python/tests/test_reporting.py | 557 +++++++++++++++ .../extension/speko_llm2_python/README.md | 13 + .../extension/speko_llm2_python/config.py | 74 +- .../extension/speko_llm2_python/extension.py | 38 +- .../speko_llm2_python/pyproject.toml | 3 + .../speko_llm2_python/tests/bin/start | 7 +- .../speko_llm2_python/tests/conftest.py | 5 +- .../speko_llm2_python/tests/requirements.txt | 3 + .../speko_llm2_python/tests/test_client.py | 2 +- .../speko_llm2_python/tests/test_extension.py | 100 +++ .../tests/test_package_contract.py | 6 + .../extension/speko_tts2_python/README.md | 14 +- .../extension/speko_tts2_python/config.py | 79 ++- .../extension/speko_tts2_python/extension.py | 48 +- .../speko_tts2_python/pyproject.toml | 3 + .../speko_tts2_python/tests/bin/start | 7 +- .../speko_tts2_python/tests/conftest.py | 5 - .../speko_tts2_python/tests/requirements.txt | 3 + .../speko_tts2_python/tests/test_client.py | 39 +- .../speko_tts2_python/tests/test_extension.py | 66 +- .../tests/test_package_contract.py | 8 + docs/ai/L1/03_code_map.md | 6 +- 34 files changed, 1658 insertions(+), 365 deletions(-) create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt create mode 100644 ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_package_contract.py create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt diff --git a/ai_agents/agents/integration_tests/asr_guarder/tests/test_connection_status.py b/ai_agents/agents/integration_tests/asr_guarder/tests/test_connection_status.py index 3ca80d7f88..9db3dec98b 100644 --- a/ai_agents/agents/integration_tests/asr_guarder/tests/test_connection_status.py +++ b/ai_agents/agents/integration_tests/asr_guarder/tests/test_connection_status.py @@ -42,13 +42,13 @@ # Extensions that report connection_status_changed via ten_ai_base. _EXTENSIONS_WITH_CONNECTION_STATUS = frozenset( { - "speko_asr_python", "azure_asr_python", "bytedance_llm_based_asr", "deepgram_asr_python", "smallest_asr_python", "rtzr_asr_python", "soniox_asr_python", + "speko_asr_python", "tencent_asr_python", } ) @@ -79,9 +79,7 @@ def validate_status_payload(payload: dict[str, Any]) -> None: "message", "metadata", ] - missing_fields = [ - field for field in required_fields if field not in payload - ] + missing_fields = [field for field in required_fields if field not in payload] if missing_fields: raise AssertionError( f"connection_status_changed missing fields: {missing_fields}" @@ -136,17 +134,18 @@ def assert_initial_connection_status_sequence( status_events: list[dict[str, Any]], ) -> None: currents = [event["current"] for event in status_events] - assert ( - "connecting" in currents - ), f"missing connecting status in sequence: {currents}" - assert ( - "connected" in currents - ), f"missing connected status in sequence: {currents}" + assert "connecting" in currents, ( + f"missing connecting status in sequence: {currents}" + ) + assert "connected" in currents, ( + f"missing connected status in sequence: {currents}" + ) connecting_index = currents.index("connecting") connected_index = currents.index("connected") assert connecting_index < connected_index, ( - "connecting must appear before connected; " f"sequence: {currents}" + "connecting must appear before connected; " + f"sequence: {currents}" ) assert_valid_connection_transitions(status_events) @@ -162,12 +161,12 @@ def assert_reconnection_status_sequence( currents = [event["current"] for event in status_events] transitions = [(event["last"], event["current"]) for event in status_events] - assert ( - "connecting" in currents - ), f"missing connecting status in reconnection sequence: {currents}" - assert ( - "disconnected" in currents - ), f"missing disconnected status in reconnection sequence: {currents}" + assert "connecting" in currents, ( + f"missing connecting status in reconnection sequence: {currents}" + ) + assert "disconnected" in currents, ( + f"missing disconnected status in reconnection sequence: {currents}" + ) if expect_retry: assert currents.count("connecting") >= 2, ( @@ -179,9 +178,9 @@ def assert_reconnection_status_sequence( f"transitions: {transitions}" ) else: - assert ( - currents.count("connecting") >= 1 - ), f"expected at least one connecting event, got: {currents}" + assert currents.count("connecting") >= 1, ( + f"expected at least one connecting event, got: {currents}" + ) class ConnectionStatusAsrTester(AsyncExtensionTester): @@ -501,9 +500,9 @@ def test_connection_status(extension_name: str, config_dir: str) -> None: tester.set_test_mode_single(extension_name, json.dumps(config)) error = tester.run() - assert ( - error is None - ), f"Test failed: {error.error_message() if error else 'Unknown error'}" + assert error is None, ( + f"Test failed: {error.error_message() if error else 'Unknown error'}" + ) assert_initial_connection_status_sequence(tester.status_events) @@ -529,9 +528,9 @@ def test_connection_status_reconnection( tester.set_test_mode_single(extension_name, json.dumps(config)) error = tester.run() - assert ( - error is None - ), f"Test failed: {error.error_message() if error else 'Unknown error'}" + assert error is None, ( + f"Test failed: {error.error_message() if error else 'Unknown error'}" + ) expect_retry = not tester.fatal_error_received assert_reconnection_status_sequence( @@ -550,7 +549,9 @@ def test_connection_status_reconnection( "Non-fatal errors should trigger retries, but received " f"{tester.errors_received} errors." ) - assert all(code == non_fatal_code for code in tester.error_codes), ( + assert all( + code == non_fatal_code for code in tester.error_codes + ), ( f"All errors should be non-fatal (code={non_fatal_code}), " f"but found unexpected codes: {tester.error_codes}" ) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md index dd0d85dd14..1a7db27aee 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -14,14 +14,16 @@ your graph. | `params.base_url` | `https://router.speko.dev` | Speko Router origin | | `params.sample_rate` | `16000` | PCM input sample rate | | `params.channels` | `1` | PCM input channel count | -| `params.language` | `en-US` | Optional routing/transcription hint | +| `params.language` | `en-US` | Language tag for routing/transcription | | `params.routing` | auto, balanced | Speko routing selection | | `params.options` | `{}` | Diarization, keywords, noise reduction, or namespaced provider options | | `params.buffer_duration_ms` | `5000` | Audio retained while disconnected; `0` discards | The transport is raw signed 16-bit little-endian PCM. A TEN `asr_finalize` -message sends Speko `input.commit` and `asr_finalize_end` is emitted after the -corresponding final transcript. +message sends Speko `input.commit`. Each finalize is serialized and acknowledged +with its own `finalize_id` and metadata, including disconnected and timeout paths. +Silent or already-finalized input completes without waiting for another transcript. +Empty vendor transcripts are not emitted as user turns. ```json { @@ -67,8 +69,26 @@ installed guarder’s `tests/bin/start` with `--extension_name speko_asr_python` and `--config_dir /absolute/path/to/configs`. Keep invalid-key configs invalid. Client/lifecycle unit tests use mocked transport and do not require credentials. -Transient disconnects reconnect on subsequent audio ingress; failed sends are -not replayed. Finalize acknowledges already-finalized input without waiting -for a duplicate transcript. Fallback timestamps retain session position while -resetting per-turn audio accounting. Permanent admission denials require correcting -the cause and restarting the extension. +Transient disconnects have one background recovery loop with delays of 0.5, 1, +2, 4, and 4 seconds. A successful handshake resets this retry budget. Exhaustion, +invalid configuration, authentication/credit failures, and unavailable explicit +routes stop further attempts until the cause is corrected and the extension is +restarted. Failed sends are not replayed; disconnected input uses the bounded +buffer. Stop cancels pending recovery and finalization. + +Segment and word timestamps are mapped onto TEN's user-audio timeline, retaining +position across connection replacement and excluding protocol silence. The raw +input dump records each ingress frame once, including buffered input. + +Run standalone tests before guarders using the same image as CI: + +```bash +task test-extension EXTENSION=agents/ten_packages/extension/speko_asr_python +task check +task lint +``` + +The standalone suite inspects emitted TEN JSON for results, finalize context, +metrics, status transitions, and redaction. Endpoint logs omit URL userinfo, +query parameters, and fragments. The single `key` vendor metadata field is +masked by TEN's reporting layer. diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py index 12fb68eba3..96ca293a0d 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py @@ -76,13 +76,13 @@ def __init__( self._listener_task: asyncio.Task[None] | None = None self._finalize_waiter: asyncio.Future[None] | None = None self._audio_generation = 0 - self._final_generation = -1 + self._final_generation = 0 self._ready = False self._closing = False @property def is_ready(self) -> bool: - return self._ready and self._ws is not None + return self._ready and not self._closing and self._ws is not None async def connect(self) -> None: if self.is_ready: @@ -117,7 +117,7 @@ async def connect(self) -> None: self.request_id = str(event.get("request_id", "")) self.route = dict(event.get("route", {})) self._audio_generation = 0 - self._final_generation = -1 + self._final_generation = 0 self._ready = True await self.on_event(event) self._listener_task = asyncio.create_task(self._listen()) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py index 291999eba1..305e249b5a 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py @@ -1,4 +1,6 @@ import copy +import re +from urllib.parse import urlsplit, urlunsplit from pathlib import Path from typing import Any @@ -25,8 +27,8 @@ class SpekoASRConfig(BaseModel): } ) options: dict[str, Any] = Field(default_factory=dict) - ready_timeout_sec: float = 10.0 - finalize_timeout_sec: float = 10.0 + ready_timeout_sec: float = Field(default=10.0, gt=0, allow_inf_nan=False) + finalize_timeout_sec: float = Field(default=10.0, gt=0, allow_inf_nan=False) buffer_duration_ms: int = 5000 @field_validator("sample_rate") @@ -65,10 +67,13 @@ def update_params(self) -> None: def _validate_required(self) -> None: if not self.api_key.strip(): raise ValueError("api_key is required") - if not self.base_url.strip(): - raise ValueError("base_url is required") + validate_base_url(self.base_url) if self.buffer_duration_ms < 0: raise ValueError("buffer_duration_ms must not be negative") + if not re.fullmatch( + r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", self.language + ): + raise ValueError("language must be a language tag, e.g. en-US") self._validate_routing() def _validate_routing(self) -> None: @@ -83,9 +88,17 @@ def _validate_routing(self) -> None: # TEN recursively merges property.json defaults into graph params. # An explicit route must not retain the default auto objective. self.routing.pop("objective", None) - model = str(self.routing.get("model", "")) - provider = str(self.routing.get("provider", "")) - if not model or (not provider and "/" not in model): + model = self.routing.get("model", "") + provider = self.routing.get("provider", "") + if ( + not isinstance(model, str) + or not model.strip() + or not isinstance(provider, str) + or ( + not provider + and ("/" not in model or not all(model.split("/", 1))) + ) + ): raise ValueError( "explicit routing requires provider and model, " "or a provider/model value" @@ -95,6 +108,56 @@ def _validate_routing(self) -> None: def to_str(self, sensitive_handling: bool = True) -> str: config = copy.deepcopy(self) - if sensitive_handling and config.api_key: + if sensitive_handling: config.api_key = utils.encrypt(config.api_key) + config.base_url = safe_url(config.base_url) return f"{config}" + + +def validate_base_url(value: str) -> None: + try: + parsed = urlsplit(value) + valid = ( + parsed.scheme in {"http", "https", "ws", "wss"} + and bool(parsed.hostname) + and not any(char.isspace() for char in value) + ) + # Accessing port also validates its syntax and range. + _ = parsed.port + except ValueError: + valid = False + if not valid: + raise ValueError("base_url must have a supported scheme and hostname") + + +def safe_url(value: str) -> str: + """Only report endpoint identity; userinfo, queries and fragments are private.""" + try: + parsed = urlsplit(value) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc.rsplit("@", 1)[-1], + parsed.path, + "", + "", + ) + ) + except ValueError: + return "" + + +def safe_error(error: Exception) -> str: + """Do not include Pydantic input values or transport URLs in diagnostics.""" + from pydantic import ValidationError + + if isinstance(error, ValidationError): + return "; ".join( + f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" + for item in error.errors(include_input=False, include_url=False) + ) + return re.sub( + r"(?:https?|wss?)://[^\s'\"<>]+", + lambda m: safe_url(m.group()), + str(error), + ) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py index 1828347f69..675247ccb0 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -4,8 +4,9 @@ # See the LICENSE file for more information. # import asyncio +import copy +import json import time -import traceback from pathlib import Path from typing import Any @@ -25,26 +26,46 @@ ModuleErrorVendorInfo, ) from ten_ai_base.struct import ASRWord -from ten_runtime import AsyncTenEnv, AudioFrame +from ten_runtime import AsyncTenEnv, AudioFrame, Data from typing_extensions import override from .client import SpekoASRClient, SpekoRouterError -from .config import SpekoASRConfig +from .config import SpekoASRConfig, safe_error, safe_url class SpekoASRExtension(AsyncASRBaseExtension): + RECONNECT_DELAYS = (0.5, 1.0, 2.0, 4.0, 4.0) + def __init__(self, name: str) -> None: super().__init__(name) self.config: SpekoASRConfig | None = None self.client: SpekoASRClient | None = None self.audio_dumper: Dumper | None = None + self._init_failed = False self._connect_started_at = 0.0 self._total_audio_bytes = 0 + self._sent_audio_bytes = 0 + self._dropped_audio_bytes = 0 self._final_cursor_ms = 0 - self._turn_start_ms = 0 + self._provider_origin_ms = 0 + self._vendor_last_end_ms = 0 + self._commit_boundary_ms: int | None = None self._permanent_error: SpekoRouterError | None = None self._reconnect_task: asyncio.Task[None] | None = None + self._commit_task: asyncio.Task[None] | None = None + self._reconnect_attempts = 0 + self._connection_epoch = 0 + self._retired_clients: list[SpekoASRClient] = [] self._route: dict[str, Any] = {} + # Base _connection_lock -> swap -> I/O. Callbacks never acquire these + # locks: connect/close/commit may themselves be awaiting a callback. + self._swap_lock = asyncio.Lock() + self._io_lock = asyncio.Lock() + # Serialize ingress metadata with finalize, but allow transcript + # callbacks while finalize is awaiting the vendor acknowledgement. + self._input_lock = asyncio.Lock() + self._metadata_lock = asyncio.Lock() + self._finalize_context: dict[str, Any] | None = None @override def vendor(self) -> str: @@ -56,9 +77,8 @@ def vendor_metadata(self) -> dict[str, Any]: return {} metadata: dict[str, Any] = { "key": self.config.api_key, - "api_key": self.config.api_key, "language": self.config.language, - "base_url": self.config.base_url, + "base_url": safe_url(self.config.base_url), "routing": self.config.routing, } if self._route: @@ -72,7 +92,7 @@ async def on_init(self, ten_env: AsyncTenEnv) -> None: try: config_json, error = await ten_env.get_property_to_json("") if error: - raise RuntimeError(f"Failed to read configuration: {error}") + raise ValueError("Failed to read configuration") self.config = SpekoASRConfig.model_validate_json(config_json) self.config.update_params() ten_env.log_info( @@ -81,96 +101,141 @@ async def on_init(self, ten_env: AsyncTenEnv) -> None: ) await self._start_dumper() except Exception as error: + self._init_failed = True self.config = None + message = safe_error(error) ten_env.log_error( - f"invalid property: {error}", - category=LOG_CATEGORY_KEY_POINT, + f"invalid property: {message}", category=LOG_CATEGORY_KEY_POINT ) await self.send_asr_error( ModuleError( module="asr", code=ModuleErrorCode.FATAL_ERROR.value, - message=str(error), - vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + message=message, ) ) + async def _ensure_connection(self) -> None: + # Guard before the base's start_connection wrapper emits connecting. + if self.stopped or self._init_failed or self._permanent_error: + return + await super()._ensure_connection() + @override async def start_connection(self) -> None: - if self._permanent_error is not None: - return - if self.config is None: - await self.on_disconnected( - code=ModuleErrorCode.FATAL_ERROR.value, - message="Speko ASR is not configured", - vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + async with self._swap_lock: + if self.stopped or self._init_failed or self._permanent_error: + await self.on_disconnected( + code=ModuleErrorCode.FATAL_ERROR.value, + message="Speko ASR connection is disabled", + ) + return + if self.config is None: + self._init_failed = True + await self.on_disconnected( + code=ModuleErrorCode.FATAL_ERROR.value, + message="Speko ASR is not configured", + ) + return + # Detach before waiting for sends/close. A retired listener cannot + # change state or report results belonging to a newer generation. + self._detach_client() + await self._close_retired_clients() + self._provider_origin_ms = self._vendor_audio_duration() + self._vendor_last_end_ms = 0 + self._commit_boundary_ms = None + self._final_cursor_ms = ( + self.audio_timeline.get_total_user_audio_duration() ) - return - if self.client is not None: - await self.client.close() + self._connect_started_at = time.monotonic() + epoch = self._connection_epoch - self._total_audio_bytes = 0 - self._final_cursor_ms = 0 - self._turn_start_ms = 0 - self._connect_started_at = time.monotonic() - self.client = SpekoASRClient( - api_key=self.config.api_key, - base_url=self.config.base_url, - configure=self._configure_frame(), - ready_timeout_sec=self.config.ready_timeout_sec, - finalize_timeout_sec=self.config.finalize_timeout_sec, - on_event=self._on_router_event, - on_disconnect=self._on_router_disconnect, - ) - try: - await self.client.connect() - except SpekoRouterError as error: - if not error.retryable and error.code in { - "authentication_failed", - "insufficient_credit", - "route_not_found", - "capability_unsupported", - }: - self._permanent_error = error - await self._report_router_error(error) - await self.on_disconnected( - code=self._module_error_code(error).value, - message=error.message, - vendor_info=self._vendor_info(error), - ) - except Exception as error: - self.ten_env.log_error( - f"vendor_error: {traceback.format_exc()}", - category=LOG_CATEGORY_VENDOR, - ) - router_error = SpekoRouterError( - "relay_error", str(error), retryable=True - ) - await self._report_router_error(router_error) - await self.on_disconnected( - code=ModuleErrorCode.NON_FATAL_ERROR.value, - message=str(error), - vendor_info=self._vendor_info(router_error), + async def on_event(event: dict[str, Any]) -> None: + if epoch == self._connection_epoch and not self.stopped: + await self._on_router_event(event) + + async def on_disconnect(error: SpekoRouterError | None) -> None: + if epoch == self._connection_epoch and not self.stopped: + await self._on_router_disconnect(error) + + client = SpekoASRClient( + api_key=self.config.api_key, + base_url=self.config.base_url, + configure=self._configure_frame(), + ready_timeout_sec=self.config.ready_timeout_sec, + finalize_timeout_sec=self.config.finalize_timeout_sec, + on_event=on_event, + on_disconnect=on_disconnect, ) + self.client = client + try: + await client.connect() + except asyncio.CancelledError: + self._detach_client() + await self._close_retired_clients() + raise + except Exception as error: + await self._reset_connection(self._router_error(error), epoch) + + def _detach_client(self) -> None: + self._connection_epoch += 1 + client, self.client = self.client, None + if client is not None: + self._retired_clients.append(client) + + async def _close_retired_clients(self, *, drain: bool = False) -> None: + # Caller owns swap. Waiting for an active send/finalize here is safe; + # failure reporting never asks for swap while holding the I/O lock. + async with self._io_lock: + while self._retired_clients: + client = self._retired_clients.pop(0) + try: + await client.close(drain=drain) + if ( + drain + and isinstance(client.usage, dict) + and client.usage + ): + await self.send_vendor_metrics({"usage": client.usage}) + except asyncio.CancelledError: + # Shutdown takes over cancelled recovery and must finish + # closing this client before dropping its ownership. + self._retired_clients.insert(0, client) + raise + except Exception as error: + self.ten_env.log_debug( + f"Retired Speko session cleanup: {safe_error(error)}" + ) @override def is_connected(self) -> bool: - return self.client is not None and self.client.is_ready + return ( + not self.stopped + and self.client is not None + and self.client.is_ready + ) @override async def stop_connection(self) -> None: + if self._commit_task is not None: + self._commit_task.cancel() task, self._reconnect_task = self._reconnect_task, None if task is not None and task is not asyncio.current_task(): task.cancel() await asyncio.gather(task, return_exceptions=True) - client, self.client = self.client, None + if self._commit_task is not None: + self._commit_task.cancel() + self._detach_client() + async with self._swap_lock: + await self._close_retired_clients(drain=True) + + @override + async def on_stop(self, ten_env: AsyncTenEnv) -> None: + self.stopped = True try: - if client is not None: - await client.close() + await super().on_stop(ten_env) finally: - dumper, self.audio_dumper = self.audio_dumper, None - if dumper is not None: - await dumper.stop() + await self._stop_dumper() @override async def on_deinit(self, ten_env: AsyncTenEnv) -> None: @@ -178,8 +243,14 @@ async def on_deinit(self, ten_env: AsyncTenEnv) -> None: try: await self.stop_connection() finally: + await self._stop_dumper() await super().on_deinit(ten_env) + async def _stop_dumper(self) -> None: + dumper, self.audio_dumper = self.audio_dumper, None + if dumper is not None: + await dumper.stop() + @override def input_audio_sample_rate(self) -> int: return self.config.sample_rate if self.config else 16000 @@ -192,119 +263,233 @@ def input_audio_channels(self) -> int: def buffer_strategy(self) -> ASRBufferConfig: if self.config is None or self.config.buffer_duration_ms == 0: return ASRBufferConfigModeDiscard() - bytes_per_ms = ( - self.input_audio_sample_rate() - * self.input_audio_channels() - * self.input_audio_sample_width() - // 1000 - ) return ASRBufferConfigModeKeep( - byte_limit=bytes_per_ms * self.config.buffer_duration_ms + byte_limit=( + self.input_audio_sample_rate() + * self.input_audio_channels() + * self.input_audio_sample_width() + * self.config.buffer_duration_ms + // 1000 + ) ) @override async def on_audio_frame( self, ten_env: AsyncTenEnv, frame: AudioFrame ) -> None: - # The base consumer buffers disconnected frames without calling send_audio. + if self.stopped: + return + # Dump original ingress once, including buffered/dropped input. Replaying + # the base queue must not duplicate bytes in this raw-input dump. + if self.audio_dumper is not None: + await self.audio_dumper.push_bytes(bytes(frame.get_buf())) if ( - not self.stopped - and not self.is_connected() + not self.is_connected() and self.connection_status != ModuleConnectionStatus.CONNECTING - and self._permanent_error is None and (self.auto_connect or self._connection_requested) - and (self._reconnect_task is None or self._reconnect_task.done()) ): - self._reconnect_task = asyncio.create_task(self._reconnect()) + self._schedule_reconnect() await super().on_audio_frame(ten_env, frame) + async def _handle_audio_frame( + self, ten_env: AsyncTenEnv, frame: AudioFrame + ) -> None: + # Coupled to TEN's serialized consumer so metadata cannot change during + # a finalize handshake. Keep all base buffering/TTFW accounting intact. + async with self._input_lock: + async with self._metadata_lock: + if self.stopped: + return + if not self.is_connected(): + size = len(frame.get_buf()) + policy = self.buffer_strategy() + if isinstance(policy, ASRBufferConfigModeDiscard): + self._record_dropped_audio(size) + return + if size > policy.byte_limit: + self._record_dropped_audio(size) + return + while self.buffered_frames_size + size > policy.byte_limit: + if self.buffered_frames.empty(): + break + dropped = self.buffered_frames.get_nowait() + dropped_size = len(dropped.get_buf()) + self.buffered_frames_size -= dropped_size + self._record_dropped_audio(dropped_size) + await super()._handle_audio_frame(ten_env, frame) + + def _record_dropped_audio(self, size: int) -> None: + before = self._audio_duration_ms(self._dropped_audio_bytes) + self._dropped_audio_bytes += size + self.audio_timeline.add_dropped_audio( + self._audio_duration_ms(self._dropped_audio_bytes) - before + ) + + def _schedule_reconnect(self) -> None: + if self.stopped or self._init_failed: + return + if self._permanent_error and not self._retired_clients: + return + if self._reconnect_task is None or self._reconnect_task.done(): + self._reconnect_task = asyncio.create_task(self._reconnect()) + async def _reconnect(self) -> None: - await self._ensure_connection() - if not self.is_connected(): - # Coalesce incoming frames during an unsuccessful connection attempt. - await asyncio.sleep(0.5) + while self._reconnect_attempts < len(self.RECONNECT_DELAYS): + delay = self.RECONNECT_DELAYS[self._reconnect_attempts] + async with self._swap_lock: + await self._close_retired_clients() + if self.stopped or self._init_failed or self._permanent_error: + return + if self.is_connected(): + return + await asyncio.sleep(delay) + self._reconnect_attempts += 1 + await self._ensure_connection() + if self.is_connected(): + return + async with self._swap_lock: + await self._close_retired_clients() + if not self.stopped and self._permanent_error is None: + self._permanent_error = SpekoRouterError( + "retry_exhausted", "Speko ASR reconnect attempts exhausted" + ) + await self._report_router_error(self._permanent_error) @override async def send_audio( self, frame: AudioFrame, session_id: str | None ) -> bool: del session_id - if not self.is_connected() or self.client is None: - return False + error = None + epoch = self._connection_epoch buffer = frame.lock_buf() try: audio = bytes(buffer) - await self.client.send_audio(audio) - self._total_audio_bytes += len(audio) - self.audio_timeline.add_user_audio( - self._audio_duration_ms(len(audio)) - ) - if self.audio_dumper is not None: - await self.audio_dumper.push_bytes(audio) - return True - except SpekoRouterError as error: - await self._reset_connection(error) - return False - except Exception as error: - await self._reset_connection( - SpekoRouterError("relay_error", str(error), retryable=True) - ) - return False + async with self._io_lock: + if not self.is_connected(): + return False + client = self.client + assert client is not None + await client.send_audio(audio) + if epoch != self._connection_epoch: + return False + # Compute each increment from cumulative bytes, not separately + # rounded frame durations (notably for 44.1 kHz input). + before_ms = self._audio_duration_ms(self._sent_audio_bytes) + self._sent_audio_bytes += len(audio) + self._total_audio_bytes += len(audio) + self.audio_timeline.add_user_audio( + self._audio_duration_ms(self._sent_audio_bytes) - before_ms + ) + return True + except Exception as caught: + error = self._router_error(caught) finally: frame.unlock_buf(buffer) + if error is not None: + await self._reset_connection(error, epoch) + return False + + @override + async def on_data(self, ten_env: AsyncTenEnv, data: Data) -> None: + if data.get_name() != "asr_finalize": + await super().on_data(ten_env, data) + return + raw, _ = data.get_property_to_json("metadata") + context = json.loads(raw) if raw else {} + if not isinstance(context, dict): + context = {} + # Lock before the base writes its single finalize_id/last_finalize_time. + async with self._input_lock: + self._finalize_context = copy.deepcopy(context) + try: + await super().on_data(ten_env, data) + finally: + self._finalize_context = None + self.last_finalize_time = None @override async def finalize(self, session_id: str | None) -> None: del session_id - if not self.is_connected() or self.client is None: - await self.send_asr_error( - ModuleError( - module="asr", - code=ModuleErrorCode.NON_FATAL_ERROR.value, - message="Speko ASR session is not connected", - vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), - ) - ) - await self.send_asr_finalize_end() - return + error = None + epoch = self._connection_epoch try: - await self.client.commit() + async with self._io_lock: + if not self.is_connected(): + return + client = self.client + assert client is not None + self._commit_task = asyncio.create_task(client.commit()) + try: + await self._commit_task + finally: + self._commit_task = None + if epoch == self._connection_epoch: + self._commit_boundary_ms = self._vendor_audio_duration() + self._total_audio_bytes = 0 + self._final_cursor_ms = max( + self._final_cursor_ms, + self.audio_timeline.get_total_user_audio_duration(), + ) except asyncio.CancelledError: if not self.stopped: raise - except SpekoRouterError as error: - if self.is_connected(): - await self._reset_connection(error) - except Exception as error: - await self._reset_connection( - SpekoRouterError("relay_error", str(error), retryable=True) - ) + except Exception as caught: + error = self._router_error(caught) finally: - self._turn_start_ms += self._audio_duration_ms( - self._total_audio_bytes - ) - self._total_audio_bytes = 0 - self._final_cursor_ms = self._turn_start_ms + if error is not None: + await self._reset_connection(error, epoch) if not self.stopped: - await self.send_asr_finalize_end() + # send_asr_finalize_end reads mutable base metadata. Snapshot + # only while reporting, after commit releases its I/O lock. + async with self._metadata_lock: + previous = self.metadata + if self._finalize_context is not None: + self.metadata = self._finalize_context + try: + await self.send_asr_finalize_end() + finally: + self.metadata = previous - async def _reset_connection(self, error: SpekoRouterError) -> None: - async with self._connection_lock: - client, self.client = self.client, None - try: - if client is not None: - await client.close() - finally: - await self._on_router_disconnect(error) - # Reconnect on the next audio frame, never replay an ambiguous send. + async def _reset_connection( + self, error: SpekoRouterError, epoch: int | None = None + ) -> None: + if self.stopped or ( + epoch is not None and epoch != self._connection_epoch + ): + return + # Invalidate before the first await. Error + close callbacks for the + # same generation now have exactly one owner and one error report. + self._detach_client() + if self._module_error_code(error) == ModuleErrorCode.FATAL_ERROR: + self._permanent_error = error + await self._report_router_error(error) + await self.on_disconnected( + code=self._module_error_code(error).value, + message=safe_error(error), + vendor_info=self._vendor_info(error), + ) + self._schedule_reconnect() + + async def _on_router_disconnect( + self, error: SpekoRouterError | None + ) -> None: + await self._reset_connection( + error + or SpekoRouterError( + "relay_error", "Speko ASR connection closed", retryable=True + ), + self._connection_epoch, + ) async def _on_router_event(self, event: dict[str, Any]) -> None: + epoch = self._connection_epoch event_type = event.get("type") if event_type == "session.ready": self._route = dict(event.get("route", {})) - # Audio may have ended before the handshake completed. The base - # only drains its disconnected buffer when another frame arrives. - # Move it ahead of queued live frames so no extra frame is needed. + self._reconnect_attempts = 0 + # Requeue in order through the base consumer, which reads metadata + # per frame. No new ingress is needed to release buffered audio. if not self.buffered_frames.empty(): pending = [] while not self.buffered_frames.empty(): @@ -314,100 +499,113 @@ async def _on_router_event(self, event: dict[str, Any]) -> None: self.buffered_frames_size = 0 for frame in pending: self.audio_frames_queue.put_nowait(frame) - connect_delay = int( - (time.monotonic() - self._connect_started_at) * 1000 - ) await self.on_connected() - await self.send_connect_delay_metrics(connect_delay) - self.ten_env.log_info( - f"vendor_status_changed: Speko ASR ready; route={self._route}", - category=LOG_CATEGORY_VENDOR, + if self.stopped or epoch != self._connection_epoch: + return + await self.send_connect_delay_metrics( + int((time.monotonic() - self._connect_started_at) * 1000) + ) + elif event_type in {"transcript.delta", "transcript.final"}: + await self._send_transcript( + event, final=event_type == "transcript.final" ) - elif event_type == "transcript.delta": - await self._send_transcript(event, final=False) - elif event_type == "transcript.final": - await self._send_transcript(event, final=True) elif event_type in {"usage.updated", "session.closed"}: - usage = dict(event.get("usage", {})) - await self.send_vendor_metrics({"usage": usage}) - - async def _on_router_disconnect( - self, error: SpekoRouterError | None - ) -> None: - if error is not None: - if not error.retryable and error.code in { - "authentication_failed", - "insufficient_credit", - "route_not_found", - "capability_unsupported", - }: - self._permanent_error = error - await self._report_router_error(error) - await self.on_disconnected( - code=( - self._module_error_code(error).value if error is not None else 0 - ), - message=error.message if error is not None else "closed", - vendor_info=( - self._vendor_info(error) if error is not None else None - ), - ) + await self.send_vendor_metrics( + {"usage": dict(event.get("usage", {}))} + ) async def _send_transcript( self, event: dict[str, Any], *, final: bool ) -> None: - segments = event.get("segments") or [] + epoch = self._connection_epoch + text = str(event.get("text", "")) + if not text.strip(): + return + segments = [] + for segment in event.get("segments") or []: + try: + start, end = int(segment["start_ms"]), int(segment["end_ms"]) + if 0 <= start <= end: + segments.append((start, end, str(segment.get("text", "")))) + except (KeyError, TypeError, ValueError): + continue words: list[ASRWord] = [] if segments: - start_ms = min(int(segment["start_ms"]) for segment in segments) - end_ms = max(int(segment["end_ms"]) for segment in segments) - for segment in segments: - segment_start = int(segment["start_ms"]) - segment_end = int(segment["end_ms"]) + first = min(start for start, _, _ in segments) + # Most providers keep a stream clock. Only rebase a reset clock + # after a completed commit, never on ordinary interim/final events. + if ( + self._commit_boundary_ms is not None + and first < self._vendor_last_end_ms + ): + self._provider_origin_ms = self._commit_boundary_ms + self._vendor_last_end_ms = 0 + self._commit_boundary_ms = None + for start, end, word in segments: + mapped_start = ( + self.audio_timeline.get_audio_duration_before_time( + self._provider_origin_ms + start + ) + ) + mapped_end = self.audio_timeline.get_audio_duration_before_time( + self._provider_origin_ms + end + ) words.append( ASRWord( - word=str(segment.get("text", "")), - start_ms=segment_start, - duration_ms=max(0, segment_end - segment_start), + word=word, + start_ms=mapped_start, + duration_ms=max(0, mapped_end - mapped_start), stable=final, ) ) + start_ms = min(word.start_ms for word in words) + end_ms = max(word.start_ms + word.duration_ms for word in words) + if final: + self._vendor_last_end_ms = max(end for _, end, _ in segments) else: start_ms = self._final_cursor_ms - end_ms = self._turn_start_ms + self._audio_duration_ms( - self._total_audio_bytes - ) - - metadata: dict[str, Any] = { - "asr_info": {"vendor": self.vendor(), "locked": False} - } - if event.get("speaker") is not None: - metadata["speaker"] = event["speaker"] - - await self.send_asr_result( - ASRResult( - text=str(event.get("text", "")), - final=final, - start_ms=start_ms, - duration_ms=max(0, end_ms - start_ms), - language=self.config.language if self.config else "en-US", - words=words, - metadata=metadata, - ) - ) + end_ms = self.audio_timeline.get_total_user_audio_duration() + async with self._metadata_lock: + if self.stopped or epoch != self._connection_epoch: + return + previous = self.metadata + metadata = copy.deepcopy(previous or {}) + info = metadata.setdefault("asr_info", {}) + if not isinstance(info, dict): + info = metadata["asr_info"] = {} + info.update(vendor=self.vendor(), locked=False) + if event.get("speaker") is not None: + info["speaker"] = event["speaker"] + self.metadata = metadata + try: + await self.send_asr_result( + ASRResult( + text=text, + final=final, + start_ms=start_ms, + duration_ms=max(0, end_ms - start_ms), + language=( + self.config.language if self.config else "en-US" + ), + words=words, + metadata=copy.deepcopy(metadata), + ) + ) + finally: + self.metadata = previous if final: self._final_cursor_ms = max(self._final_cursor_ms, end_ms) async def _report_router_error(self, error: SpekoRouterError) -> None: self.ten_env.log_error( - f"vendor_error: code={error.code}, message={error.message}", + f"vendor_error: code={error.code}, message={safe_error(error)}", category=LOG_CATEGORY_VENDOR, ) await self.send_asr_error( ModuleError( module="asr", code=self._module_error_code(error).value, - message=error.message, + message=safe_error(error), vendor_info=self._vendor_info(error), ) ) @@ -431,7 +629,11 @@ def _configure_frame(self) -> dict[str, Any]: return frame async def _start_dumper(self) -> None: - if self.config is None or not self.config.dump: + if ( + self.config is None + or not self.config.dump + or self.audio_dumper is not None + ): return dump_path = Path(self.config.dump_path) if dump_path.suffix != ".pcm": @@ -441,21 +643,43 @@ async def _start_dumper(self) -> None: await self.audio_dumper.start() def _audio_duration_ms(self, byte_count: int) -> int: - bytes_per_second = ( - self.input_audio_sample_rate() - * self.input_audio_channels() - * self.input_audio_sample_width() + return ( + byte_count + * 1000 + // ( + self.input_audio_sample_rate() + * self.input_audio_channels() + * self.input_audio_sample_width() + ) + ) + + def _vendor_audio_duration(self) -> int: + return ( + self.audio_timeline.total_user_audio_duration + + self.audio_timeline.total_silence_audio_duration ) - return int(byte_count * 1000 / bytes_per_second) @staticmethod def _module_error_code(error: SpekoRouterError) -> ModuleErrorCode: - fatal_codes = {"authentication_failed", "insufficient_credit"} - if error.code in fatal_codes: + if error.code in { + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + "retry_exhausted", + }: return ModuleErrorCode.FATAL_ERROR return ModuleErrorCode.NON_FATAL_ERROR + @staticmethod + def _router_error(error: Exception) -> SpekoRouterError: + if isinstance(error, SpekoRouterError): + return error + return SpekoRouterError( + "relay_error", safe_error(error), retryable=True + ) + def _vendor_info(self, error: SpekoRouterError) -> ModuleErrorVendorInfo: return ModuleErrorVendorInfo( - vendor=self.vendor(), code=error.code, message=error.message + vendor=self.vendor(), code=error.code, message=safe_error(error) ) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml index 972d45cf2b..8633245e33 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml @@ -7,3 +7,6 @@ dependencies = [ "typing-extensions>=4.15.0", "websockets>=14.0,<16", ] + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start index 0a273c6ea9..6a44d35471 100755 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start @@ -3,5 +3,8 @@ set -e cd "$(dirname "${BASH_SOURCE[0]}")/../.." -export PYTHONPATH=".:${PYTHONPATH}" -pytest -q tests/ "$@" +export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/app/ten_packages/system/ten_runtime_python/interface:.ten/app/ten_packages/system/ten_ai_base/interface:${PYTHONPATH}" +# uv's cached test environment keeps test dependencies out of provider installs. +uv run --no-project --with-requirements requirements.txt \ + --with-requirements tests/requirements.txt \ + pytest -q -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py index df5aba222a..8388452693 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py @@ -1,9 +1,4 @@ import sys from pathlib import Path -from types import ModuleType - -package_name = Path(__file__).parents[1].name -addon_name = f"{package_name}.addon" -sys.modules.setdefault(addon_name, ModuleType(addon_name)) sys.path.insert(0, str(Path(__file__).parents[2])) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt new file mode 100644 index 0000000000..294f1c49ca --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest>=8,<9 +pytest-asyncio>=0.24,<1 +aiofiles>=24 diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py index 2672bd3dc8..5feb48e6ec 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py @@ -3,7 +3,11 @@ from unittest.mock import AsyncMock, patch import pytest -from client import SpekoASRClient, SpekoRouterError, websocket_url +from speko_asr_python.client import ( + SpekoASRClient, + SpekoRouterError, + websocket_url, +) class FakeWebSocket: @@ -73,7 +77,8 @@ async def test_streams_audio_and_waits_for_final_transcript(): client = make_client(events, disconnects) with patch( - "client.websockets.connect", AsyncMock(return_value=websocket) + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), ) as connect: await client.connect() await client.send_audio(b"\x00\x01") @@ -121,7 +126,10 @@ async def test_terminal_error_is_normalized(): events = [] disconnects = [] client = make_client(events, disconnects) - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() await websocket.messages.put( json.dumps( @@ -158,8 +166,12 @@ async def test_timeout_does_not_poison_next_commit(): websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) client = make_client([], []) client.finalize_timeout_sec = 0.01 - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() + await client.send_audio(b"\x00\x00") with pytest.raises(SpekoRouterError, match="Timed out"): await client.commit() assert client._finalize_waiter is None @@ -182,7 +194,10 @@ async def test_auto_final_before_commit_and_new_audio(): received.set() if event["type"] == "transcript.final" else None ) ) - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() await client.send_audio(b"first") websocket.messages.put_nowait( @@ -207,7 +222,10 @@ async def test_close_delivers_terminal_usage(): websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) events = [] client = make_client(events, []) - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() websocket.messages.put_nowait( json.dumps( @@ -226,8 +244,12 @@ async def test_close_delivers_terminal_usage(): async def test_failed_commit_send_clears_waiter(): websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) client = make_client([], []) - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() + await client.send_audio(b"\x00\x00") websocket.send = AsyncMock(side_effect=OSError("socket closed")) with pytest.raises(OSError): await client.commit() @@ -260,7 +282,7 @@ async def test_upgrade_preserves_classified_error(status, code, retryable): ).encode() client = make_client([], []) with patch( - "client.websockets.connect", + "speko_asr_python.client.websockets.connect", AsyncMock(side_effect=InvalidStatus(response)), ): with pytest.raises(SpekoRouterError) as caught: @@ -274,7 +296,10 @@ async def test_upgrade_preserves_classified_error(status, code, retryable): async def test_cancel_during_handshake_closes_socket(): websocket = FakeWebSocket() client = make_client([], []) - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): connecting = asyncio.create_task(client.connect()) await asyncio.sleep(0) connecting.cancel() @@ -282,3 +307,17 @@ async def test_cancel_during_handshake_closes_socket(): await connecting assert websocket.closed assert not client.is_ready + + +@pytest.mark.asyncio +async def test_commit_without_audio_completes_without_protocol_wait(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + client = make_client([], []) + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + await client.connect() + await asyncio.wait_for(client.commit(), 0.1) + assert len(websocket.sent) == 1 + await client.close(drain=False) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py index 4f8fe3127f..daec36e185 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py @@ -1,3 +1,5 @@ +import asyncio + from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -62,6 +64,7 @@ async def test_timeout_disconnects_and_completes_finalize_once(): "request_timeout", "timeout", retryable=True ) await extension.finalize("session") + await extension.stop_connection() client.close.assert_awaited_once() assert extension.client is None extension.send_asr_finalize_end.assert_awaited_once() @@ -124,8 +127,7 @@ async def test_deinit_stops_dumper_once_even_after_connection_failure(): "speko_asr_python.extension.AsyncASRBaseExtension.on_deinit", new_callable=AsyncMock, ): - with pytest.raises(OSError): - await extension.on_deinit(extension.ten_env) + await extension.on_deinit(extension.ten_env) await extension.on_deinit(extension.ten_env) dumper.stop.assert_awaited_once() assert extension.audio_dumper is None @@ -134,7 +136,7 @@ async def test_deinit_stops_dumper_once_even_after_connection_failure(): def test_metadata_contains_key_and_language(): extension, _ = make_extension() assert extension.vendor_metadata()["key"] == "test-key" - assert extension.vendor_metadata()["api_key"] == "test-key" + assert "api_key" not in extension.vendor_metadata() assert extension.vendor_metadata()["language"] @@ -170,6 +172,7 @@ async def connect(): entered.set() await asyncio.Future() + extension.RECONNECT_DELAYS = (0,) extension._ensure_connection = AsyncMock(side_effect=connect) await asyncio.wait_for( extension.on_audio_frame(extension.ten_env, frame()), 0.1 @@ -238,15 +241,13 @@ async def test_reconnect_waits_for_failed_session_cleanup(): extension, client = make_extension() entered, release = asyncio.Event(), asyncio.Event() - async def close(): + async def close(**_kwargs): entered.set() await release.wait() client.close.side_effect = close - resetting = asyncio.create_task( - extension._reset_connection( - SpekoRouterError("relay_error", "broken", retryable=True) - ) + await extension._reset_connection( + SpekoRouterError("relay_error", "broken", retryable=True) ) await entered.wait() @@ -258,6 +259,5 @@ async def reconnect(): await asyncio.sleep(0) extension.start_connection.assert_not_awaited() release.set() - await resetting await extension._reconnect_task extension.start_connection.assert_awaited_once() diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py index 2d5f981b50..c1833fc580 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py @@ -32,3 +32,11 @@ def test_guarder_config_properties_are_declared(config_path): assert set(config) <= set(properties) assert "params" in config assert set(config["params"]) <= set(properties["params"]["properties"]) + + +def test_real_addon_registration(): + from speko_asr_python import addon + from ten_runtime.addon_manager import _AddonManager + + assert callable(_AddonManager._registry["speko_asr_python"]) + assert addon.__file__.endswith("addon.py") diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py new file mode 100644 index 0000000000..297ba2682e --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py @@ -0,0 +1,557 @@ +"""Exercise actual TEN reporting, with only the provider transport mocked.""" + +import asyncio +import copy +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from ten_ai_base.message import ModuleErrorCode +from ten_runtime import Data + +from speko_asr_python.client import SpekoRouterError +from speko_asr_python.config import SpekoASRConfig +from speko_asr_python.extension import SpekoASRExtension + + +def extension_and_events(): + extension = SpekoASRExtension("speko") + extension.config = SpekoASRConfig(api_key="test-secret-key-long") + extension.ten_env = MagicMock() + events = [] + + async def send(data): + raw, _ = data.get_property_to_json("") + events.append((data.get_name(), json.loads(raw))) + + extension.ten_env.send_data = AsyncMock(side_effect=send) + extension.client = MagicMock( + is_ready=True, + send_audio=AsyncMock(), + commit=AsyncMock(), + close=AsyncMock(), + ) + return extension, events + + +def audio(metadata=None, size=32000): + frame = MagicMock() + frame.get_buf.return_value = bytes(size) + frame.lock_buf.return_value = bytes(size) + frame.get_property_to_json.return_value = ( + json.dumps(metadata or {"session_id": "audio-session"}), + None, + ) + return frame + + +def finalize_data(finalize_id, session_id): + data = Data.create("asr_finalize") + data.set_property_string("finalize_id", finalize_id) + data.set_property_from_json( + "metadata", + json.dumps({"session_id": session_id, "custom": finalize_id}), + ) + return data + + +def payloads(events, name): + return [payload for kind, payload in events if kind == name] + + +@pytest.mark.asyncio +async def test_emitted_results_preserve_metadata_and_ids_without_empty_turns(): + ext, events = extension_and_events() + metadata = { + "session_id": "audio-session", + "custom": "keep", + "asr_info": {"caller": 1}, + } + await ext._handle_audio_frame(ext.ten_env, audio(metadata)) + original = copy.deepcopy(ext.metadata) + ext.first_audio_time = asyncio.get_running_loop().time() - 0.1 + for kind, text in ( + ("transcript.delta", "hello"), + ("transcript.final", "hello"), + ("transcript.final", ""), + ("transcript.delta", " "), + ): + await ext._on_router_event({"type": kind, "text": text, "speaker": "A"}) + results = payloads(events, "asr_result") + assert len(results) == 2 + assert results[0]["id"] == results[1]["id"] + assert results[1]["final"] is True + assert results[1]["language"] == "en-US" + assert "session_id" not in results[1] + assert results[1]["metadata"] == { + **metadata, + "asr_info": { + "caller": 1, + "vendor": "speko", + "locked": False, + "speaker": "A", + }, + } + assert ext.metadata == original + metrics = payloads(events, "metrics") + assert metrics and metrics[0]["metadata"]["session_id"] == "audio-session" + + +@pytest.mark.asyncio +async def test_concurrent_finalize_echoes_each_request_and_blocks_later_audio(): + ext, events = extension_and_events() + await ext._handle_audio_frame(ext.ten_env, audio()) + entered, release = asyncio.Event(), asyncio.Event() + + async def commit(): + entered.set() + await release.wait() + await ext._on_router_event({"type": "transcript.final", "text": "done"}) + + ext.client.commit.side_effect = commit + first = asyncio.create_task( + ext.on_data(ext.ten_env, finalize_data("one", "session-one")) + ) + await entered.wait() + second = asyncio.create_task( + ext.on_data(ext.ten_env, finalize_data("two", "session-two")) + ) + later_audio = asyncio.create_task( + ext._handle_audio_frame(ext.ten_env, audio({"session_id": "next"})) + ) + await asyncio.sleep(0) + assert ext.client.commit.await_count == 1 + assert not later_audio.done() + release.set() + await asyncio.wait_for(asyncio.gather(first, second, later_audio), 1) + acknowledgements = payloads(events, "asr_finalize_end") + assert [ + (item["finalize_id"], item["metadata"]["session_id"]) + for item in acknowledgements + ] == [("one", "session-one"), ("two", "session-two")] + assert all( + item["metadata"]["custom"] == item["finalize_id"] + for item in acknowledgements + ) + kinds = [kind for kind, _ in events] + assert kinds.index("asr_result") < kinds.index("asr_finalize_end") + assert ext.last_finalize_time is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + [ + None, + SpekoRouterError("request_timeout", "timeout", retryable=True), + OSError("closed"), + ], +) +async def test_finalize_acknowledges_disconnection_and_errors(failure): + ext, events = extension_and_events() + if failure is None: + ext.client = None + else: + ext.client.commit.side_effect = failure + await ext.on_data(ext.ten_env, finalize_data("id", "final-session")) + await ext.stop_connection() + assert payloads(events, "asr_finalize_end") == [ + { + "finalize_id": "id", + "metadata": {"session_id": "final-session", "custom": "id"}, + } + ] + + +@pytest.mark.asyncio +async def test_real_status_events_redact_key_and_url(): + ext, events = extension_and_events() + ext.config.base_url = "https://alice:password@example.com/router?token=private&signature=signed#secret" + clients = [] + + def factory(**kwargs): + client = MagicMock(is_ready=True, close=AsyncMock()) + + async def connect(): + await kwargs["on_event"]({"type": "session.ready"}) + + client.connect = AsyncMock(side_effect=connect) + clients.append((client, kwargs)) + return client + + with patch( + "speko_asr_python.extension.SpekoASRClient", side_effect=factory + ): + await ext.start_connection() + await ext.start_connection() + before = list(events) + await clients[0][1]["on_disconnect"]( + SpekoRouterError("relay_error", "old", retryable=True) + ) + await clients[0][1]["on_event"]( + {"type": "transcript.final", "text": "stale"} + ) + assert events == before + assert ext.client is clients[1][0] + await ext.on_stop(ext.ten_env) + statuses = payloads(events, "connection_status_changed") + assert [item["current"] for item in statuses] == [ + "connecting", + "connected", + "connecting", + "connected", + "disconnected", + ] + serialized = json.dumps(events) + ext.config.to_str() + for secret in ( + "test-secret-key-long", + "alice", + "password", + "private", + "signed", + "#secret", + ): + assert secret not in serialized + vendor = statuses[1]["metadata"]["vendor_metadata"] + assert vendor["base_url"] == "https://example.com/router" + assert "key" in vendor and "api_key" not in vendor + + +@pytest.mark.asyncio +async def test_invalid_configuration_is_latched_before_any_connect_attempt(): + ext, events = extension_and_events() + ext.client = None + ext.ten_env.get_property_bool = AsyncMock(return_value=(True, None)) + ext.ten_env.get_property_to_json = AsyncMock( + return_value=( + json.dumps( + {"params": {"api_key": "private-value", "base_url": "invalid"}} + ), + None, + ) + ) + # The real base initializer starts a consumer; stop it through the lifecycle. + with patch("speko_asr_python.extension.SpekoASRClient") as constructor: + await ext.on_init(ext.ten_env) + await ext.on_start(ext.ten_env) + for _ in range(3): + await ext.on_audio_frame(ext.ten_env, audio()) + await ext.on_data(ext.ten_env, Data.create("trigger_connect")) + await ext.on_stop(ext.ten_env) + constructor.assert_not_called() + assert len(payloads(events, "error")) == 1 + assert payloads(events, "error")[0]["code"] == int( + ModuleErrorCode.FATAL_ERROR.value + ) + assert "private-value" not in json.dumps(events) + + +@pytest.mark.asyncio +async def test_bounded_reconnect_escalates_and_success_resets_counter(): + ext, events = extension_and_events() + ext.client = None + ext.start_connection = AsyncMock() + real_sleep = asyncio.sleep + delays = [] + + async def sleep(delay): + delays.append(delay) + await real_sleep(0) + + with patch("speko_asr_python.extension.asyncio.sleep", side_effect=sleep): + await ext._reconnect() + assert delays == [0.5, 1, 2, 4, 4] + assert ext.start_connection.await_count == 5 + assert ext._reconnect_attempts == 5 + assert payloads(events, "error")[-1]["code"] == int( + ModuleErrorCode.FATAL_ERROR.value + ) + await ext._ensure_connection() + assert ext.start_connection.await_count == 5 + ext._permanent_error = None + ext.client = MagicMock(is_ready=True) + ext.on_connected = AsyncMock() + await ext._on_router_event({"type": "session.ready"}) + assert ext._reconnect_attempts == 0 + + +@pytest.mark.asyncio +async def test_stop_cancels_commit_and_never_reports_after_shutdown(): + ext, events = extension_and_events() + started = asyncio.Event() + + async def commit(): + started.set() + await asyncio.Future() + + ext.client.commit.side_effect = commit + pending = asyncio.create_task( + ext.on_data(ext.ten_env, finalize_data("stop", "session")) + ) + await started.wait() + await asyncio.wait_for(ext.on_stop(ext.ten_env), 1) + await asyncio.wait_for(pending, 1) + assert not payloads(events, "asr_finalize_end") + assert ext.client is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reset_clock", [False, True]) +async def test_segment_timeline_two_cycles_and_reconnect(reset_clock): + ext, events = extension_and_events() + await ext._handle_audio_frame(ext.ten_env, audio()) + await ext._on_router_event( + { + "type": "transcript.final", + "text": "one", + "segments": [{"start_ms": 0, "end_ms": 1000, "text": "one"}], + } + ) + await ext.on_data(ext.ten_env, finalize_data("one", "audio-session")) + await ext._handle_audio_frame(ext.ten_env, audio()) + start = 0 if reset_clock else 1000 + await ext._on_router_event( + { + "type": "transcript.final", + "text": "two", + "segments": [ + {"start_ms": start, "end_ms": start + 1000, "text": "two"} + ], + } + ) + + def factory(**kwargs): + async def connect(): + await kwargs["on_event"]({"type": "session.ready"}) + + return MagicMock( + is_ready=True, + close=AsyncMock(), + send_audio=AsyncMock(), + connect=AsyncMock(side_effect=connect), + ) + + with patch( + "speko_asr_python.extension.SpekoASRClient", side_effect=factory + ): + await ext.start_connection() + await ext._handle_audio_frame(ext.ten_env, audio()) + await ext._on_router_event( + { + "type": "transcript.final", + "text": "three", + "segments": [{"start_ms": 0, "end_ms": 1000, "text": "three"}], + } + ) + results = payloads(events, "asr_result") + assert [(item["start_ms"], item["duration_ms"]) for item in results] == [ + (0, 1000), + (1000, 1000), + (2000, 1000), + ] + assert [item["words"][0]["start_ms"] for item in results] == [0, 1000, 2000] + await ext.on_stop(ext.ten_env) + + +@pytest.mark.asyncio +async def test_buffered_frames_keep_session_metadata_and_dump_exact_ingress( + tmp_path, +): + ext, events = extension_and_events() + ext.config.dump = True + ext.config.dump_path = str(tmp_path / "audio.pcm") + await ext._start_dumper() + client = ext.client + client.is_ready = False + ext.auto_connect = False + for session in ("old", "new"): + frame = audio({"session_id": session}) + await ext.on_audio_frame(ext.ten_env, frame) + await ext._handle_audio_frame( + ext.ten_env, ext.audio_frames_queue.get_nowait() + ) + client.is_ready = True + ext.on_connected = AsyncMock() + await ext._on_router_event({"type": "session.ready"}) + for _ in range(2): + await ext._handle_audio_frame( + ext.ten_env, ext.audio_frames_queue.get_nowait() + ) + await ext._on_router_event( + {"type": "transcript.final", "text": "hello"} + ) + assert [ + item["metadata"]["session_id"] + for item in payloads(events, "asr_result") + ] == ["old", "new"] + await ext.stop_connection() + assert ext.audio_dumper is not None + await ext.on_deinit(ext.ten_env) + assert (tmp_path / "audio.pcm").read_bytes() == bytes(64000) + + +@pytest.mark.asyncio +async def test_dropped_audio_and_fractional_frames_have_correct_timeline(): + ext, _ = extension_and_events() + ext.client.is_ready = False + ext.config.buffer_duration_ms = 10 + await ext._handle_audio_frame(ext.ten_env, audio(size=640)) + assert ext.buffered_frames_size == 0 + assert ext.audio_timeline.total_dropped_audio_duration == 20 + ext.client.is_ready = True + ext.config.sample_rate = 44100 + for _ in range(100): + await ext._handle_audio_frame(ext.ten_env, audio(size=882)) + assert ext.audio_timeline.total_user_audio_duration == 1000 + assert ext.audio_timeline.get_audio_duration_before_time(500) == 520 + + +@pytest.mark.parametrize( + "params", + [ + {"base_url": "ftp://example.com"}, + {"base_url": "https://"}, + {"base_url": "https://example.com:bad"}, + {"language": "bad tag"}, + {"ready_timeout_sec": 0}, + {"finalize_timeout_sec": float("inf")}, + {"sample_rate": 1}, + {"channels": 0}, + {"routing": {"mode": "wrong"}}, + ], +) +def test_invalid_config_values(params): + with pytest.raises(ValueError): + config = SpekoASRConfig(api_key="key", params=params) + config.update_params() + + +@pytest.mark.asyncio +async def test_stop_waits_for_active_send_and_closes_once(): + ext, events = extension_and_events() + entered, release = asyncio.Event(), asyncio.Event() + client = ext.client + + async def send(_audio): + entered.set() + await release.wait() + + client.send_audio.side_effect = send + frame = audio() + sending = asyncio.create_task(ext.send_audio(frame, "session")) + await entered.wait() + stopping = asyncio.create_task(ext.on_stop(ext.ten_env)) + await asyncio.sleep(0) + assert not ext.is_connected() + client.close.assert_not_awaited() + release.set() + await asyncio.wait_for(asyncio.gather(sending, stopping), 1) + client.close.assert_awaited_once() + frame.unlock_buf.assert_called_once() + before = len(events) + await ext.on_audio_frame(ext.ten_env, audio()) + assert len(events) == before + + +@pytest.mark.asyncio +async def test_error_and_late_disconnect_have_one_report(): + ext, events = extension_and_events() + ext.auto_connect = False + epoch = ext._connection_epoch + error = SpekoRouterError("route_not_found", "no route") + await ext._reset_connection(error, epoch) + await ext._reset_connection(error, epoch) + await ext.stop_connection() + errors = payloads(events, "error") + assert len(errors) == 1 + assert errors[0]["code"] == int(ModuleErrorCode.FATAL_ERROR.value) + assert errors[0]["vendor_info"] == { + "vendor": "speko", + "code": "route_not_found", + "message": "no route", + } + + +@pytest.mark.asyncio +async def test_metrics_have_session_metadata_and_timeline_excludes_silence(): + ext, events = extension_and_events() + await ext._handle_audio_frame(ext.ten_env, audio()) + ext.audio_timeline.add_silence_audio(500) + await ext._handle_audio_frame(ext.ten_env, audio()) + + async def commit(): + await ext._on_router_event( + { + "type": "transcript.final", + "text": "hello", + "segments": [ + {"text": "hello", "start_ms": 1500, "end_ms": 2500} + ], + } + ) + + ext.client.commit.side_effect = commit + await ext.on_data(ext.ten_env, finalize_data("id", "audio-session")) + await ext._on_router_event( + {"type": "usage.updated", "usage": {"duration_ms": 2000}} + ) + await ext._send_audio_actual_send_metrics() + metric_messages = payloads(events, "metrics") + metrics = { + key: value + for payload in metric_messages + for key, value in payload["metrics"].items() + } + assert { + "ttfw", + "ttlw", + "vendor_metrics", + "actual_send", + "actual_send_delta", + } <= metrics.keys() + assert all( + payload["metadata"]["session_id"] == "audio-session" + for payload in metric_messages + ) + assert metrics["actual_send"] == 2500 + result = payloads(events, "asr_result")[0] + assert (result["start_ms"], result["duration_ms"]) == (1000, 1000) + + +@pytest.mark.asyncio +async def test_shutdown_reports_drained_usage_without_old_callbacks(): + ext, events = extension_and_events() + ext.metadata = {"session_id": "session"} + ext.session_id = "session" + client = ext.client + client.usage = {"duration_ms": 1000} + await ext.on_stop(ext.ten_env) + client.close.assert_awaited_once_with(drain=True) + metrics = payloads(events, "metrics") + assert any( + item["metrics"].get("vendor_metrics") + == {"usage": {"duration_ms": 1000}} + for item in metrics + ) + + +@pytest.mark.asyncio +async def test_stop_finishes_cleanup_cancelled_with_reconnect(): + ext, _ = extension_and_events() + client = ext.client + entered = asyncio.Event() + + async def close(*, drain): + if not drain: + entered.set() + await asyncio.Future() + + client.close.side_effect = close + await ext._reset_connection( + SpekoRouterError("relay_error", "closed", retryable=True) + ) + await entered.wait() + await asyncio.wait_for(ext.on_stop(ext.ten_env), 1) + assert client.close.await_count == 2 + assert client.close.await_args.kwargs == {"drain": True} + assert not ext._retired_clients diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md b/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md index 4ed23585b3..5226c802af 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md @@ -38,3 +38,16 @@ configured in explicit mode. Speko's launch Responses contract accepts text content only. Image content is rejected locally with a clear error instead of being silently dropped. + +## Testing + +From `ai_agents/`: + +```bash +task test-extension EXTENSION=agents/ten_packages/extension/speko_llm2_python +``` + +The suite covers SSE transport, streaming and non-streaming completion through +TEN's synchronous async-generator interface, explicit-route normalization, +shutdown cleanup, and config redaction. Explicit routing removes any automatic +objective inherited from TEN's recursive property merge. diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py index 298598460a..bbc7b244f4 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py @@ -1,4 +1,6 @@ import copy +import re +from urllib.parse import urlsplit, urlunsplit from typing import Any from pydantic import BaseModel, Field @@ -12,7 +14,7 @@ class SpekoLLM2Config(BaseModel): max_output_tokens: int = Field(default=512, ge=1) temperature: float | None = Field(default=0.7, ge=0, le=2) top_p: float | None = Field(default=None, gt=0, le=1) - timeout_sec: float = Field(default=60.0, gt=0) + timeout_sec: float = Field(default=60.0, gt=0, allow_inf_nan=False) routing: dict[str, Any] = Field( default_factory=lambda: { "mode": "auto", @@ -23,8 +25,7 @@ class SpekoLLM2Config(BaseModel): def validate_required(self) -> None: if not self.api_key.strip(): raise ValueError("api_key is required") - if not self.base_url.strip(): - raise ValueError("base_url is required") + validate_base_url(self.base_url) if self.max_output_tokens < 1: raise ValueError("max_output_tokens must be positive") self._validate_routing() @@ -38,9 +39,18 @@ def _validate_routing(self) -> None: raise ValueError("unsupported routing objective") return if mode == "explicit": - model = str(self.routing.get("model", "")) - provider = str(self.routing.get("provider", "")) - if not model or (not provider and "/" not in model): + self.routing.pop("objective", None) + model = self.routing.get("model", "") + provider = self.routing.get("provider", "") + if ( + not isinstance(model, str) + or not model.strip() + or not isinstance(provider, str) + or ( + not provider + and ("/" not in model or not all(model.split("/", 1))) + ) + ): raise ValueError( "explicit routing requires provider and model, " "or a provider/model value" @@ -50,6 +60,56 @@ def _validate_routing(self) -> None: def to_str(self, sensitive_handling: bool = True) -> str: config = copy.deepcopy(self) - if sensitive_handling and config.api_key: + if sensitive_handling: config.api_key = utils.encrypt(config.api_key) + config.base_url = safe_url(config.base_url) return f"{config}" + + +def validate_base_url(value: str) -> None: + try: + parsed = urlsplit(value) + valid = ( + parsed.scheme in {"http", "https"} + and bool(parsed.hostname) + and not any(char.isspace() for char in value) + ) + # Accessing port also validates its syntax and range. + _ = parsed.port + except ValueError: + valid = False + if not valid: + raise ValueError("base_url must have a supported scheme and hostname") + + +def safe_url(value: str) -> str: + """Only report endpoint identity; userinfo, queries and fragments are private.""" + try: + parsed = urlsplit(value) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc.rsplit("@", 1)[-1], + parsed.path, + "", + "", + ) + ) + except ValueError: + return "" + + +def safe_error(error: Exception) -> str: + """Do not include Pydantic input values or transport URLs in diagnostics.""" + from pydantic import ValidationError + + if isinstance(error, ValidationError): + return "; ".join( + f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" + for item in error.errors(include_input=False, include_url=False) + ) + return re.sub( + r"(?:https?|wss?)://[^\s'\"<>]+", + lambda m: safe_url(m.group()), + str(error), + ) diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py index 7e7e5595a8..5bdcba63dc 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py @@ -37,7 +37,7 @@ from ten_runtime import AsyncTenEnv, Data from .client import SpekoLLMClient, SpekoRouterError -from .config import SpekoLLM2Config +from .config import SpekoLLM2Config, safe_error class SpekoLLM2Extension(AsyncLLM2BaseExtension): @@ -66,23 +66,25 @@ async def on_start(self, ten_env: AsyncTenEnv) -> None: except Exception as error: self.config = None ten_env.log_error( - f"invalid property: {error}", + f"invalid property: {safe_error(error)}", category=LOG_CATEGORY_KEY_POINT, ) await self._send_error( ModuleError( module=ModuleType.LLM, code=ModuleErrorCode.FATAL_ERROR.value, - message=str(error), + message=safe_error(error), vendor_info=ModuleErrorVendorInfo(vendor="speko"), ) ) async def on_stop(self, ten_env: AsyncTenEnv) -> None: - await super().on_stop(ten_env) - if self.client is not None: - await self.client.close() - self.client = None + try: + await super().on_stop(ten_env) + finally: + client, self.client = self.client, None + if client is not None: + await client.close() async def on_retrieve_prompt( self, ten_env: AsyncTenEnv, request: LLMRequestRetrievePrompt @@ -92,7 +94,12 @@ async def on_retrieve_prompt( prompt=self.config.prompt if self.config else "" ) - async def on_call_chat_completion( + def on_call_chat_completion( + self, ten_env: AsyncTenEnv, request: LLMRequest + ) -> AsyncGenerator[LLMResponse, None]: + return self._chat_completion(ten_env, request) + + async def _chat_completion( self, ten_env: AsyncTenEnv, request: LLMRequest ) -> AsyncGenerator[LLMResponse, None]: if self.config is None or self.client is None: @@ -188,7 +195,7 @@ async def on_call_chat_completion( except SpekoRouterError as error: await self._report_router_error(error, request) raise RuntimeError( - f"Speko Router request failed ({error.code}): {error.message}" + f"Speko Router request failed ({error.code}): {safe_error(error)}" ) from error def _build_payload(self, request: LLMRequest) -> dict[str, Any]: @@ -394,7 +401,7 @@ async def _report_router_error( self, error: SpekoRouterError, request: LLMRequest ) -> None: self.ten_env.log_error( - f"vendor_error: code={error.code}, message={error.message}", + f"vendor_error: code={error.code}, message={safe_error(error)}", category=LOG_CATEGORY_VENDOR, ) await self._send_error( @@ -402,11 +409,11 @@ async def _report_router_error( id=request.request_id, module=ModuleType.LLM, code=self._module_error_code(error).value, - message=error.message, + message=safe_error(error), vendor_info=ModuleErrorVendorInfo( vendor="speko", code=error.code, - message=error.message, + message=safe_error(error), ), ) ) @@ -418,7 +425,12 @@ async def _send_error(self, error: ModuleError) -> None: @staticmethod def _module_error_code(error: SpekoRouterError) -> ModuleErrorCode: - fatal_codes = {"authentication_failed", "insufficient_credit"} + fatal_codes = { + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + } if error.code in fatal_codes: return ModuleErrorCode.FATAL_ERROR return ModuleErrorCode.NON_FATAL_ERROR diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml index e182a06cd8..5171415ced 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml @@ -6,3 +6,6 @@ dependencies = [ "httpx>=0.27.0,<1", "pydantic>=2.13.4", ] + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start index 0a273c6ea9..6a44d35471 100755 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start @@ -3,5 +3,8 @@ set -e cd "$(dirname "${BASH_SOURCE[0]}")/../.." -export PYTHONPATH=".:${PYTHONPATH}" -pytest -q tests/ "$@" +export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/app/ten_packages/system/ten_runtime_python/interface:.ten/app/ten_packages/system/ten_ai_base/interface:${PYTHONPATH}" +# uv's cached test environment keeps test dependencies out of provider installs. +uv run --no-project --with-requirements requirements.txt \ + --with-requirements tests/requirements.txt \ + pytest -q -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py index 93300358db..8388452693 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py @@ -1,7 +1,4 @@ import sys from pathlib import Path -from types import ModuleType -package_name = Path(__file__).parents[1].name -addon_name = f"{package_name}.addon" -sys.modules.setdefault(addon_name, ModuleType(addon_name)) +sys.path.insert(0, str(Path(__file__).parents[2])) diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt new file mode 100644 index 0000000000..294f1c49ca --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest>=8,<9 +pytest-asyncio>=0.24,<1 +aiofiles>=24 diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py index fa3461fb0e..b506796b13 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py @@ -2,7 +2,7 @@ import httpx import pytest -from client import SpekoLLMClient, SpekoRouterError +from speko_llm2_python.client import SpekoLLMClient, SpekoRouterError def sse(*events): diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py new file mode 100644 index 0000000000..0ccc1742a6 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py @@ -0,0 +1,100 @@ +import inspect +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from ten_ai_base.struct import LLMRequest + +from speko_llm2_python.client import SSEEvent +from speko_llm2_python.config import SpekoLLM2Config +from speko_llm2_python.extension import SpekoLLM2Extension + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_completion_interface_and_explicit_route(streaming): + extension = SpekoLLM2Extension("speko") + extension.config = SpekoLLM2Config( + api_key="key", + routing={ + "mode": "explicit", + "objective": "balanced", + "provider": "openai", + "model": "test-model", + }, + ) + extension.config.validate_required() + extension.ten_env = MagicMock(send_data=AsyncMock()) + captured = [] + + async def stream(payload, **_kwargs): + captured.append(payload) + yield SSEEvent("response.created", {"response_id": "response"}) + yield SSEEvent("response.text.delta", {"delta": "hello"}) + yield SSEEvent("response.completed", {"usage": {"output_tokens": 1}}) + + async def complete(payload, **_kwargs): + captured.append(payload) + return { + "id": "response", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + } + ], + "usage": {"output_tokens": 1}, + } + + extension.client = MagicMock( + stream=stream, complete=complete, route={"provider": "openai"} + ) + request = LLMRequest(request_id="request", messages=[], streaming=streaming) + iterator = extension.on_call_chat_completion(extension.ten_env, request) + assert inspect.isasyncgen(iterator) + responses = [response async for response in iterator] + assert responses[-1].content == "hello" + assert responses[-1].response_id == "response" + assert captured[0]["routing"] == { + "mode": "explicit", + "provider": "openai", + "model": "test-model", + } + raw, _ = extension.ten_env.send_data.await_args.args[ + 0 + ].get_property_to_json("") + assert json.loads(raw)["metadata"]["request_id"] == "request" + + +@pytest.mark.asyncio +async def test_stop_cancels_base_before_closing_client_even_on_error(): + extension = SpekoLLM2Extension("speko") + calls = [] + + async def stop(_ten_env): + calls.append("cancel") + raise RuntimeError("base failure") + + async def close(): + calls.append("close") + + extension.client = MagicMock(close=AsyncMock(side_effect=close)) + with patch( + "speko_llm2_python.extension.AsyncLLM2BaseExtension.on_stop", + side_effect=stop, + ): + with pytest.raises(RuntimeError): + await extension.on_stop(MagicMock()) + assert calls == ["cancel", "close"] + assert extension.client is None + + +def test_config_log_redacts_url(): + config = SpekoLLM2Config( + api_key="test-secret-key-long", + base_url="https://user:password@example.com?signature=private", + ) + config.validate_required() + for secret in ("test-secret-key-long", "password", "private"): + assert secret not in config.to_str() diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_package_contract.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_package_contract.py new file mode 100644 index 0000000000..1cabeed1cd --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_package_contract.py @@ -0,0 +1,6 @@ +def test_real_addon_registration(): + from speko_llm2_python import addon + from ten_runtime.addon_manager import _AddonManager + + assert callable(_AddonManager._registry["speko_llm2_python"]) + assert addon.__file__.endswith("addon.py") diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md index aa9fad3b75..7d232ce63f 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md @@ -14,7 +14,7 @@ your graph. | `params.base_url` | `https://router.speko.dev` | Speko Router origin | | `params.sample_rate` | `24000` | PCM output sample rate | | `params.channels` | `1` | PCM output channel count | -| `params.language` | `en` | Optional routing/voice hint | +| `params.language` | `en` | Language tag for routing/voice | | `params.voice` | empty | Optional provider voice identifier | | `params.routing` | auto, balanced | Speko routing selection | @@ -70,3 +70,15 @@ is never replayed; later text can continue unless a permanent admission denial blocks the session. A terminal close error produces an error completion and still releases the request. Non-retryable denials are not automatically retried. + +Standalone regression gate from `ai_agents/`: + +```bash +task test-extension EXTENSION=agents/ten_packages/extension/speko_tts2_python +``` + +Authentication, credit, missing-route and unsupported-capability errors are fatal +and latch the session. Temporary transport failures remain non-fatal. Dump +recorders are released on completion, cancellation, request replacement, and +shutdown, including close-error paths. Connection-status tests inspect emitted +TEN JSON to verify key and endpoint redaction. diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py index 2b5a47d6bd..be2c387670 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py @@ -1,4 +1,6 @@ import copy +import re +from urllib.parse import urlsplit, urlunsplit from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -24,8 +26,8 @@ class SpekoTTS2Config(BaseModel): "objective": "balanced", } ) - ready_timeout_sec: float = 10.0 - receive_timeout_sec: float = 30.0 + ready_timeout_sec: float = Field(default=10.0, gt=0, allow_inf_nan=False) + receive_timeout_sec: float = Field(default=30.0, gt=0, allow_inf_nan=False) @field_validator("sample_rate") @classmethod @@ -62,8 +64,11 @@ def update_params(self) -> None: def _validate_required(self) -> None: if not self.api_key.strip(): raise ValueError("api_key is required") - if not self.base_url.strip(): - raise ValueError("base_url is required") + validate_base_url(self.base_url) + if not re.fullmatch( + r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", self.language + ): + raise ValueError("language must be a language tag, e.g. en-US") self._validate_routing() def _validate_routing(self) -> None: @@ -78,9 +83,17 @@ def _validate_routing(self) -> None: # TEN recursively merges property.json defaults into graph params. # An explicit route must not retain the default auto objective. self.routing.pop("objective", None) - model = str(self.routing.get("model", "")) - provider = str(self.routing.get("provider", "")) - if not model or (not provider and "/" not in model): + model = self.routing.get("model", "") + provider = self.routing.get("provider", "") + if ( + not isinstance(model, str) + or not model.strip() + or not isinstance(provider, str) + or ( + not provider + and ("/" not in model or not all(model.split("/", 1))) + ) + ): raise ValueError( "explicit routing requires provider and model, " "or a provider/model value" @@ -90,6 +103,56 @@ def _validate_routing(self) -> None: def to_str(self, sensitive_handling: bool = True) -> str: config = copy.deepcopy(self) - if sensitive_handling and config.api_key: + if sensitive_handling: config.api_key = utils.encrypt(config.api_key) + config.base_url = safe_url(config.base_url) return f"{config}" + + +def validate_base_url(value: str) -> None: + try: + parsed = urlsplit(value) + valid = ( + parsed.scheme in {"http", "https", "ws", "wss"} + and bool(parsed.hostname) + and not any(char.isspace() for char in value) + ) + # Accessing port also validates its syntax and range. + _ = parsed.port + except ValueError: + valid = False + if not valid: + raise ValueError("base_url must have a supported scheme and hostname") + + +def safe_url(value: str) -> str: + """Only report endpoint identity; userinfo, queries and fragments are private.""" + try: + parsed = urlsplit(value) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc.rsplit("@", 1)[-1], + parsed.path, + "", + "", + ) + ) + except ValueError: + return "" + + +def safe_error(error: Exception) -> str: + """Do not include Pydantic input values or transport URLs in diagnostics.""" + from pydantic import ValidationError + + if isinstance(error, ValidationError): + return "; ".join( + f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" + for item in error.errors(include_input=False, include_url=False) + ) + return re.sub( + r"(?:https?|wss?)://[^\s'\"<>]+", + lambda m: safe_url(m.group()), + str(error), + ) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py index f09eba19d1..f7531f05f8 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py @@ -26,7 +26,7 @@ SpekoTTSClient, SpekoTTSEventType, ) -from .config import SpekoTTS2Config +from .config import SpekoTTS2Config, safe_error, safe_url class SpekoTTS2Extension(AsyncTTS2BaseExtension): @@ -62,7 +62,7 @@ async def on_init(self, ten_env: AsyncTenEnv) -> None: except Exception as error: self.config = None ten_env.log_error( - f"invalid property: {error}", + f"invalid property: {safe_error(error)}", category=LOG_CATEGORY_KEY_POINT, ) await self.send_tts_error( @@ -70,7 +70,7 @@ async def on_init(self, ten_env: AsyncTenEnv) -> None: error=ModuleError( module=ModuleType.TTS, code=ModuleErrorCode.FATAL_ERROR.value, - message=str(error), + message=safe_error(error), vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), ), ) @@ -98,9 +98,8 @@ def vendor_metadata(self) -> dict[str, Any]: return {} metadata: dict[str, Any] = { "key": self.config.api_key, - "api_key": self.config.api_key, "language": self.config.language, - "base_url": self.config.base_url, + "base_url": safe_url(self.config.base_url), "routing": self.config.routing, } if self._route: @@ -154,7 +153,9 @@ async def _request_tts(self, text_input: TTSTextInput) -> None: if self._cancelled or self._stopped: return await self._handle_router_error( - SpekoRouterError("relay_error", str(error), retryable=True), + SpekoRouterError( + "relay_error", safe_error(error), retryable=True + ), text_input.text_input_end, ) @@ -175,7 +176,9 @@ async def cancel_tts(self) -> None: router_error = ( error if isinstance(error, SpekoRouterError) - else SpekoRouterError("relay_error", str(error), retryable=True) + else SpekoRouterError( + "relay_error", safe_error(error), retryable=True + ) ) await self.send_tts_error( request_id, self._make_module_error(router_error) @@ -196,8 +199,13 @@ async def cancel_tts(self) -> None: await self._flush_recorder(request_id) async def _begin_request(self, text_input: TTSTextInput) -> None: - if self.client is not None: - await self._close_client() + previous_request = self.current_request_id + try: + if self.client is not None: + await self._close_client() + finally: + if previous_request is not None: + await self._flush_recorder(previous_request) self.current_request_id = text_input.request_id self._route = {} self._router_usage = {} @@ -288,7 +296,7 @@ async def _finalize_request( close_error if isinstance(close_error, SpekoRouterError) else SpekoRouterError( - "relay_error", str(close_error), retryable=True + "relay_error", safe_error(close_error), retryable=True ) ) reason = TTSAudioEndReason.ERROR @@ -328,19 +336,14 @@ async def _finalize_request( ) def _make_module_error(self, error: SpekoRouterError) -> ModuleError: - if not error.retryable and error.code in { - "authentication_failed", - "insufficient_credit", - "route_not_found", - "capability_unsupported", - }: + if self._module_error_code(error) == ModuleErrorCode.FATAL_ERROR: self._permanent_error = error return ModuleError( module=ModuleType.TTS, code=self._module_error_code(error).value, - message=error.message, + message=safe_error(error), vendor_info=ModuleErrorVendorInfo( - vendor=self.vendor(), code=error.code, message=error.message + vendor=self.vendor(), code=error.code, message=safe_error(error) ), ) @@ -350,7 +353,7 @@ async def _handle_router_error( module_error = self._make_module_error(error) await self.on_disconnected( code=module_error.code, - message=error.message, + message=safe_error(error), vendor_info=module_error.vendor_info, ) if self._finalized: @@ -442,7 +445,12 @@ def _audio_duration_ms(self) -> int: @staticmethod def _module_error_code(error: SpekoRouterError) -> ModuleErrorCode: - fatal_codes = {"authentication_failed", "insufficient_credit"} + fatal_codes = { + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + } if error.code in fatal_codes: return ModuleErrorCode.FATAL_ERROR return ModuleErrorCode.NON_FATAL_ERROR diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml index 157058b75d..772cc2b00e 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml @@ -6,3 +6,6 @@ dependencies = [ "pydantic>=2.13.4", "websockets>=14.0,<16", ] + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start index 0a273c6ea9..6a44d35471 100755 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start @@ -3,5 +3,8 @@ set -e cd "$(dirname "${BASH_SOURCE[0]}")/../.." -export PYTHONPATH=".:${PYTHONPATH}" -pytest -q tests/ "$@" +export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/app/ten_packages/system/ten_runtime_python/interface:.ten/app/ten_packages/system/ten_ai_base/interface:${PYTHONPATH}" +# uv's cached test environment keeps test dependencies out of provider installs. +uv run --no-project --with-requirements requirements.txt \ + --with-requirements tests/requirements.txt \ + pytest -q -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py index df5aba222a..8388452693 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py @@ -1,9 +1,4 @@ import sys from pathlib import Path -from types import ModuleType - -package_name = Path(__file__).parents[1].name -addon_name = f"{package_name}.addon" -sys.modules.setdefault(addon_name, ModuleType(addon_name)) sys.path.insert(0, str(Path(__file__).parents[2])) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt new file mode 100644 index 0000000000..294f1c49ca --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest>=8,<9 +pytest-asyncio>=0.24,<1 +aiofiles>=24 diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py index e25056b649..d42ad33835 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest -from client import ( +from speko_tts2_python.client import ( SpekoRouterError, SpekoTTSClient, SpekoTTSEventType, @@ -67,7 +67,10 @@ async def test_append_commit_audio_and_usage(): json.dumps({"type": "session.closed", "usage": {"characters": 5}}), ) client = make_client() - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() events = [event async for event in client.stream_text("hello")] @@ -104,7 +107,10 @@ async def test_cancel_sends_protocol_cancel_before_close(): ) ) client = make_client() - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() await client.cancel() @@ -145,7 +151,10 @@ async def test_router_error_keeps_retryable_flag(): ), ) client = make_client() - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() with pytest.raises(SpekoRouterError) as caught: _ = [event async for event in client.stream_text("hello")] @@ -185,7 +194,10 @@ async def test_close_surfaces_terminal_router_error(): ), ) client = make_client() - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() _ = [event async for event in client.stream_text("hello")] with pytest.raises(SpekoRouterError) as caught: @@ -220,7 +232,7 @@ async def test_upgrade_preserves_classified_error(status, code, retryable): ).encode() client = make_client() with patch( - "client.websockets.connect", + "speko_tts2_python.client.websockets.connect", AsyncMock(side_effect=InvalidStatus(response)), ): with pytest.raises(SpekoRouterError) as caught: @@ -234,7 +246,10 @@ async def test_upgrade_preserves_classified_error(status, code, retryable): async def test_cancel_during_handshake_closes_socket(): websocket = FakeWebSocket() client = make_client() - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): connecting = asyncio.create_task(client.connect()) await asyncio.sleep(0) connecting.cancel() @@ -255,7 +270,10 @@ async def test_cancel_stops_buffered_audio_without_reading_closed_socket( b"audio", ) client = make_client() - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() stream = client.stream_text("hello") assert (await anext(stream)).type == SpekoTTSEventType.TTFB @@ -270,7 +288,10 @@ async def test_cancel_stops_buffered_audio_without_reading_closed_socket( async def test_cancel_discards_a_receive_completing_during_close(): websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) client = make_client() - with patch("client.websockets.connect", AsyncMock(return_value=websocket)): + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): await client.connect() stream = client.stream_text("hello") receiving = asyncio.create_task(anext(stream)) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py index da01f5bc3e..f689ae3605 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py @@ -171,7 +171,7 @@ async def test_recorder_failure_still_releases_request(): def test_vendor_metadata(): extension = make_extension() assert extension.vendor_metadata()["key"] == "test-key" - assert extension.vendor_metadata()["api_key"] == "test-key" + assert "api_key" not in extension.vendor_metadata() assert extension.vendor_metadata()["language"] == "en" @@ -356,3 +356,67 @@ async def stream(*_): extension._stream_text.side_effect = None await extension.request_tts(text("next", "next", True)) extension.finish_request.assert_awaited_once() + + +@pytest.mark.parametrize( + "code", + [ + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + ], +) +def test_terminal_error_classification_matches_latch(code): + from ten_ai_base.message import ModuleErrorCode + + extension = make_extension() + error = SpekoRouterError(code, "terminal") + result = extension._make_module_error(error) + assert str(result.code) == ModuleErrorCode.FATAL_ERROR.value + assert extension._permanent_error is error + assert result.vendor_info.code == code + + +@pytest.mark.asyncio +@pytest.mark.parametrize("close_fails", [False, True]) +async def test_replaced_request_recorder_is_flushed_even_if_close_fails( + close_fails, +): + extension = SpekoTTS2Extension("speko") + extension.config = SpekoTTS2Config(api_key="key") + extension.ten_env = MagicMock() + extension.on_disconnected = AsyncMock() + extension.current_request_id = "previous" + recorder = MagicMock(flush=AsyncMock()) + extension._recorders["previous"] = recorder + extension.client = MagicMock( + close=AsyncMock(side_effect=OSError("close") if close_fails else None) + ) + if close_fails: + with pytest.raises(OSError): + await extension._begin_request(text(request_id="next")) + else: + await extension._begin_request(text(request_id="next")) + recorder.flush.assert_awaited_once() + assert "previous" not in extension._recorders + + +@pytest.mark.asyncio +async def test_connection_event_redacts_credentials_and_url(): + import json + + extension = SpekoTTS2Extension("speko") + extension.config = SpekoTTS2Config( + api_key="test-secret-key-long", + base_url="https://user:password@example.com?token=private#secret", + ) + extension.ten_env = MagicMock(send_data=AsyncMock()) + await extension.on_connecting() + data = extension.ten_env.send_data.await_args.args[0] + raw, _ = data.get_property_to_json("") + vendor = json.loads(raw)["metadata"]["vendor_metadata"] + assert vendor["base_url"] == "https://example.com" + assert "key" in vendor and "api_key" not in vendor + for secret in ("test-secret-key-long", "password", "private", "#secret"): + assert secret not in raw + extension.config.to_str() diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py index 2d5f981b50..36c29aa847 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py @@ -32,3 +32,11 @@ def test_guarder_config_properties_are_declared(config_path): assert set(config) <= set(properties) assert "params" in config assert set(config["params"]) <= set(properties["params"]["properties"]) + + +def test_real_addon_registration(): + from speko_tts2_python import addon + from ten_runtime.addon_manager import _AddonManager + + assert callable(_AddonManager._registry["speko_tts2_python"]) + assert addon.__file__.endswith("addon.py") diff --git a/docs/ai/L1/03_code_map.md b/docs/ai/L1/03_code_map.md index ab83a51aea..ccaa5929b1 100644 --- a/docs/ai/L1/03_code_map.md +++ b/docs/ai/L1/03_code_map.md @@ -47,9 +47,9 @@ Other repo-root directories: `core/` (C runtime), `packages/` (example/core exte | Category | Count | Examples | | --------- | ----- | ----------------------------------------------------------- | -| ASR | 10+ | `deepgram_asr_python`, `azure_asr_python`, `aws_asr_python` | -| TTS | 15+ | `deepgram_tts`, `elevenlabs_tts2_python`, `cartesia_tts` | -| LLM | 9+ | `openai_llm2_python`, `anthropic_llm2_python`, `gemini_llm2_python`, `bedrock_llm_python` | +| ASR | 10+ | `deepgram_asr_python`, `azure_asr_python`, `aws_asr_python`, `speko_asr_python` | +| TTS | 15+ | `deepgram_tts`, `elevenlabs_tts2_python`, `cartesia_tts`, `speko_tts2_python` | +| LLM | 9+ | `openai_llm2_python`, `anthropic_llm2_python`, `gemini_llm2_python`, `bedrock_llm_python`, `speko_llm2_python` | | Avatar | 5+ | `heygen_avatar_python`, `anam_avatar_python` | | Tools | 8+ | `bingsearch_tool_python`, `vision_tool_python` | | Transport | 3+ | `agora_rtc`, `websocket_server`, `http_server_python` | From 654090f8d304dfcf3e6e83bba3e698d89b318958 Mon Sep 17 00:00:00 2001 From: Abat Date: Fri, 18 Sep 2026 14:01:51 +0500 Subject: [PATCH 04/17] fix: drain accepted asr audio before finalizing (#2307) --- .../extension/speko_asr_python/README.md | 4 + .../extension/speko_asr_python/extension.py | 51 ++++++++-- .../extension/speko_asr_python/pyproject.toml | 3 - .../speko_asr_python/tests/bin/start | 3 +- .../speko_asr_python/tests/test_reporting.py | 96 +++++++++++++++++++ .../speko_llm2_python/pyproject.toml | 3 - .../speko_llm2_python/tests/bin/start | 3 +- .../speko_tts2_python/pyproject.toml | 3 - .../speko_tts2_python/tests/bin/start | 3 +- 9 files changed, 149 insertions(+), 20 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md index 1a7db27aee..f83f9c0be1 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -76,6 +76,10 @@ routes stop further attempts until the cause is corrected and the extension is restarted. Failed sends are not replayed; disconnected input uses the bounded buffer. Stop cancels pending recovery and finalization. +Finalization waits for an in-progress handshake and previously accepted audio +to drain through the base consumer before committing. Later ingress waits until +that finalize completes, preserving audio order and the request's metadata. + Segment and word timestamps are mapped onto TEN's user-audio timeline, retaining position across connection replacement and excluding protocol silence. The raw input dump records each ingress frame once, including buffered input. diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py index 675247ccb0..02e50af731 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -65,6 +65,9 @@ def __init__(self, name: str) -> None: # callbacks while finalize is awaiting the vendor acknowledgement. self._input_lock = asyncio.Lock() self._metadata_lock = asyncio.Lock() + self._ingress_lock = asyncio.Lock() + self._audio_queue_drained = asyncio.Event() + self._audio_queue_drained.set() self._finalize_context: dict[str, Any] | None = None @override @@ -232,6 +235,7 @@ async def stop_connection(self) -> None: @override async def on_stop(self, ten_env: AsyncTenEnv) -> None: self.stopped = True + self._audio_queue_drained.set() try: await super().on_stop(ten_env) finally: @@ -240,6 +244,7 @@ async def on_stop(self, ten_env: AsyncTenEnv) -> None: @override async def on_deinit(self, ten_env: AsyncTenEnv) -> None: self.stopped = True + self._audio_queue_drained.set() try: await self.stop_connection() finally: @@ -276,6 +281,12 @@ def buffer_strategy(self) -> ASRBufferConfig: @override async def on_audio_frame( self, ten_env: AsyncTenEnv, frame: AudioFrame + ) -> None: + async with self._ingress_lock: + await self._enqueue_audio_frame(ten_env, frame) + + async def _enqueue_audio_frame( + self, ten_env: AsyncTenEnv, frame: AudioFrame ) -> None: if self.stopped: return @@ -289,10 +300,20 @@ async def on_audio_frame( and (self.auto_connect or self._connection_requested) ): self._schedule_reconnect() + self._audio_queue_drained.clear() await super().on_audio_frame(ten_env, frame) async def _handle_audio_frame( self, ten_env: AsyncTenEnv, frame: AudioFrame + ) -> None: + try: + await self._consume_audio_frame(ten_env, frame) + finally: + if self.audio_frames_queue.empty(): + self._audio_queue_drained.set() + + async def _consume_audio_frame( + self, ten_env: AsyncTenEnv, frame: AudioFrame ) -> None: # Coupled to TEN's serialized consumer so metadata cannot change during # a finalize handshake. Keep all base buffering/TTFW accounting intact. @@ -399,14 +420,27 @@ async def on_data(self, ten_env: AsyncTenEnv, data: Data) -> None: context = json.loads(raw) if raw else {} if not isinstance(context, dict): context = {} - # Lock before the base writes its single finalize_id/last_finalize_time. - async with self._input_lock: - self._finalize_context = copy.deepcopy(context) - try: - await super().on_data(ten_env, data) - finally: - self._finalize_context = None - self.last_finalize_time = None + # Hold later ingress while previously accepted audio drains through the + # base consumer. A finalize during the initial handshake must commit + # that buffered audio after ready, rather than acknowledge it early. + async with self._ingress_lock: + if self.connection_status == ModuleConnectionStatus.CONNECTING: + async with self._connection_lock: + pass # Join the existing owner; do not start another retry. + while not self.stopped and not self.audio_frames_queue.empty(): + self._audio_queue_drained.clear() + await self._audio_queue_drained.wait() + if self.stopped: + return + # Lock before the base writes finalize_id/last_finalize_time, also + # waiting for the consumer's last in-flight send to finish. + async with self._input_lock: + self._finalize_context = copy.deepcopy(context) + try: + await super().on_data(ten_env, data) + finally: + self._finalize_context = None + self.last_finalize_time = None @override async def finalize(self, session_id: str | None) -> None: @@ -497,6 +531,7 @@ async def _on_router_event(self, event: dict[str, Any]) -> None: while not self.audio_frames_queue.empty(): pending.append(self.audio_frames_queue.get_nowait()) self.buffered_frames_size = 0 + self._audio_queue_drained.clear() for frame in pending: self.audio_frames_queue.put_nowait(frame) await self.on_connected() diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml index 8633245e33..972d45cf2b 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/pyproject.toml @@ -7,6 +7,3 @@ dependencies = [ "typing-extensions>=4.15.0", "websockets>=14.0,<16", ] - -[tool.pytest.ini_options] -asyncio_default_fixture_loop_scope = "function" diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start index 6a44d35471..db2a4b032f 100755 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start @@ -7,4 +7,5 @@ export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/ap # uv's cached test environment keeps test dependencies out of provider installs. uv run --no-project --with-requirements requirements.txt \ --with-requirements tests/requirements.txt \ - pytest -q -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" + pytest -q -o asyncio_default_fixture_loop_scope=function \ + -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py index 297ba2682e..989463529e 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py @@ -555,3 +555,99 @@ async def close(*, drain): assert client.close.await_count == 2 assert client.close.await_args.kwargs == {"drain": True} assert not ext._retired_clients + + +@pytest.mark.asyncio +async def test_finalize_joins_handshake_and_commits_buffered_audio_before_ack(): + ext, events = extension_and_events() + ext.client = None + entered, ready = asyncio.Event(), asyncio.Event() + order = [] + + def factory(**kwargs): + client = MagicMock(is_ready=False, close=AsyncMock(), usage={}) + + async def connect(): + entered.set() + await ready.wait() + client.is_ready = True + await kwargs["on_event"]({"type": "session.ready"}) + + async def send(_audio): + order.append(ext.session_id) + + async def commit(): + order.append("commit") + await kwargs["on_event"]( + {"type": "transcript.final", "text": "buffered speech"} + ) + + client.connect = connect + client.send_audio = send + client.commit = commit + return client + + consumer = asyncio.create_task(ext._audio_frame_consumer()) + try: + with patch("speko_asr_python.extension.SpekoASRClient", factory): + connection = asyncio.create_task(ext._ensure_connection()) + await entered.wait() + for session in ("first", "second"): + await ext.on_audio_frame( + ext.ten_env, audio({"session_id": session}) + ) + await ext._audio_queue_drained.wait() + assert ext.buffered_frames.qsize() == 2 + finalize = asyncio.create_task( + ext.on_data(ext.ten_env, finalize_data("early", "turn")) + ) + await asyncio.sleep(0) + later = asyncio.create_task( + ext.on_audio_frame(ext.ten_env, audio({"session_id": "later"})) + ) + await asyncio.sleep(0) + assert not payloads(events, "asr_finalize_end") + assert not later.done() + ready.set() + await asyncio.wait_for( + asyncio.gather(connection, finalize, later), 1 + ) + await ext._audio_queue_drained.wait() + assert order == ["first", "second", "commit", "later"] + assert len(payloads(events, "asr_result")) == 1 + assert payloads(events, "asr_finalize_end")[0]["finalize_id"] == "early" + finally: + await ext.on_stop(ext.ten_env) + consumer.cancel() + await asyncio.gather(consumer, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_finalize_waits_for_already_queued_audio_on_ready_connection(): + ext, events = extension_and_events() + order = [] + + async def send(_audio): + await asyncio.sleep(0) + order.append(ext.session_id) + + async def commit(): + order.append("commit") + + ext.client.send_audio.side_effect = send + ext.client.commit.side_effect = commit + consumer = asyncio.create_task(ext._audio_frame_consumer()) + try: + for session in ("first", "second"): + await ext.on_audio_frame( + ext.ten_env, audio({"session_id": session}) + ) + await asyncio.wait_for( + ext.on_data(ext.ten_env, finalize_data("queued", "turn")), 1 + ) + assert order == ["first", "second", "commit"] + assert len(payloads(events, "asr_finalize_end")) == 1 + finally: + await ext.on_stop(ext.ten_env) + consumer.cancel() + await asyncio.gather(consumer, return_exceptions=True) diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml index 5171415ced..e182a06cd8 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/pyproject.toml @@ -6,6 +6,3 @@ dependencies = [ "httpx>=0.27.0,<1", "pydantic>=2.13.4", ] - -[tool.pytest.ini_options] -asyncio_default_fixture_loop_scope = "function" diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start index 6a44d35471..db2a4b032f 100755 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start @@ -7,4 +7,5 @@ export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/ap # uv's cached test environment keeps test dependencies out of provider installs. uv run --no-project --with-requirements requirements.txt \ --with-requirements tests/requirements.txt \ - pytest -q -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" + pytest -q -o asyncio_default_fixture_loop_scope=function \ + -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml index 772cc2b00e..157058b75d 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/pyproject.toml @@ -6,6 +6,3 @@ dependencies = [ "pydantic>=2.13.4", "websockets>=14.0,<16", ] - -[tool.pytest.ini_options] -asyncio_default_fixture_loop_scope = "function" diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start index 6a44d35471..db2a4b032f 100755 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start @@ -7,4 +7,5 @@ export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/ap # uv's cached test environment keeps test dependencies out of provider installs. uv run --no-project --with-requirements requirements.txt \ --with-requirements tests/requirements.txt \ - pytest -q -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" + pytest -q -o asyncio_default_fixture_loop_scope=function \ + -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" From 1200732679d87046c313f5f2e783b5c32be29dd9 Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 00:10:33 +0500 Subject: [PATCH 05/17] test(speko): run standalone suites on pytest 8.4 pytest 8.4 removed PytestUnhandledCoroutineWarning, so the runner's -W filter failed to parse with the newest pytest 8.x. pytest >= 8.4 fails unrun async tests by itself; require it and drop the filter. Co-Authored-By: Claude Opus 5.5 --- .../ten_packages/extension/speko_asr_python/tests/bin/start | 4 ++-- .../extension/speko_asr_python/tests/requirements.txt | 2 +- .../ten_packages/extension/speko_llm2_python/tests/bin/start | 4 ++-- .../extension/speko_llm2_python/tests/requirements.txt | 2 +- .../ten_packages/extension/speko_tts2_python/tests/bin/start | 4 ++-- .../extension/speko_tts2_python/tests/requirements.txt | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start index db2a4b032f..4970bbf164 100755 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start @@ -5,7 +5,7 @@ set -e cd "$(dirname "${BASH_SOURCE[0]}")/../.." export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/app/ten_packages/system/ten_runtime_python/interface:.ten/app/ten_packages/system/ten_ai_base/interface:${PYTHONPATH}" # uv's cached test environment keeps test dependencies out of provider installs. +# pytest >= 8.4 fails async tests that no plugin runs. uv run --no-project --with-requirements requirements.txt \ --with-requirements tests/requirements.txt \ - pytest -q -o asyncio_default_fixture_loop_scope=function \ - -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" + pytest -q -o asyncio_default_fixture_loop_scope=function tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt index 294f1c49ca..88ced285ca 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt @@ -1,3 +1,3 @@ -pytest>=8,<9 +pytest>=8.4,<9 pytest-asyncio>=0.24,<1 aiofiles>=24 diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start index db2a4b032f..4970bbf164 100755 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start @@ -5,7 +5,7 @@ set -e cd "$(dirname "${BASH_SOURCE[0]}")/../.." export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/app/ten_packages/system/ten_runtime_python/interface:.ten/app/ten_packages/system/ten_ai_base/interface:${PYTHONPATH}" # uv's cached test environment keeps test dependencies out of provider installs. +# pytest >= 8.4 fails async tests that no plugin runs. uv run --no-project --with-requirements requirements.txt \ --with-requirements tests/requirements.txt \ - pytest -q -o asyncio_default_fixture_loop_scope=function \ - -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" + pytest -q -o asyncio_default_fixture_loop_scope=function tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt index 294f1c49ca..88ced285ca 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt @@ -1,3 +1,3 @@ -pytest>=8,<9 +pytest>=8.4,<9 pytest-asyncio>=0.24,<1 aiofiles>=24 diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start index db2a4b032f..4970bbf164 100755 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start @@ -5,7 +5,7 @@ set -e cd "$(dirname "${BASH_SOURCE[0]}")/../.." export PYTHONPATH=".:.ten/app/ten_packages/system/ten_runtime_python/lib:.ten/app/ten_packages/system/ten_runtime_python/interface:.ten/app/ten_packages/system/ten_ai_base/interface:${PYTHONPATH}" # uv's cached test environment keeps test dependencies out of provider installs. +# pytest >= 8.4 fails async tests that no plugin runs. uv run --no-project --with-requirements requirements.txt \ --with-requirements tests/requirements.txt \ - pytest -q -o asyncio_default_fixture_loop_scope=function \ - -W error::pytest.PytestUnhandledCoroutineWarning tests/ "$@" + pytest -q -o asyncio_default_fixture_loop_scope=function tests/ "$@" diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt index 294f1c49ca..88ced285ca 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt @@ -1,3 +1,3 @@ -pytest>=8,<9 +pytest>=8.4,<9 pytest-asyncio>=0.24,<1 aiofiles>=24 From 049d67283fe5be4a1bbe3a2618d92bb0a6b71a5c Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 00:10:33 +0500 Subject: [PATCH 06/17] fix(speko_asr_python): bound buffering and harden setup and commit failures - buffer_duration_ms must be 0..30000 and the disconnected buffer is capped at 10 MiB; overflow drops the oldest frames into the dropped-audio timeline. - A non-retryable error from the handshake (HTTP 400 on upgrade, or an error event before session.ready with retryable=false) is fatal: one fatal error, latched, no reconnect task. One classification drives latch and report. - A commit the Router does not acknowledge (provider already finalized the turn) keeps the session: warn, no asr_error, one asr_finalize_end, and the turn cursor advances as for an acknowledged commit. An empty transcript.final acknowledges the commit. Real disconnects still reset. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_asr_python/README.md | 21 +- .../extension/speko_asr_python/client.py | 6 +- .../extension/speko_asr_python/config.py | 15 +- .../extension/speko_asr_python/extension.py | 78 +++++-- .../speko_asr_python/tests/test_client.py | 72 ++++++- .../speko_asr_python/tests/test_extension.py | 70 +++++- .../speko_asr_python/tests/test_reporting.py | 200 ++++++++++++++++-- 7 files changed, 418 insertions(+), 44 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md index f83f9c0be1..2ced91bbb2 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -17,12 +17,23 @@ your graph. | `params.language` | `en-US` | Language tag for routing/transcription | | `params.routing` | auto, balanced | Speko routing selection | | `params.options` | `{}` | Diarization, keywords, noise reduction, or namespaced provider options | -| `params.buffer_duration_ms` | `5000` | Audio retained while disconnected; `0` discards | +| `params.buffer_duration_ms` | `5000` | Audio retained while disconnected, `0`–`30000`; `0` discards | + +The disconnected buffer is held in memory and is also capped at 10 MiB, which +binds before 30 seconds at high sample rates or channel counts. When a frame +would overflow it, the oldest buffered frames are dropped (a frame larger than +the whole buffer is dropped itself). Dropped frames are never sent; they are +recorded as dropped audio in the TEN audio timeline, so later transcript +timestamps still line up with the caller's audio. The transport is raw signed 16-bit little-endian PCM. A TEN `asr_finalize` message sends Speko `input.commit`. Each finalize is serialized and acknowledged with its own `finalize_id` and metadata, including disconnected and timeout paths. Silent or already-finalized input completes without waiting for another transcript. +An empty `transcript.final` acknowledges the commit. If no final arrives within +`finalize_timeout_sec` (the provider may already have finalized the turn), the +extension logs a warning, keeps the session, and still completes the finalize; +only a real disconnect reports an error and reconnects. Empty vendor transcripts are not emitted as user turns. ```json @@ -71,9 +82,11 @@ Client/lifecycle unit tests use mocked transport and do not require credentials. Transient disconnects have one background recovery loop with delays of 0.5, 1, 2, 4, and 4 seconds. A successful handshake resets this retry budget. Exhaustion, -invalid configuration, authentication/credit failures, and unavailable explicit -routes stop further attempts until the cause is corrected and the extension is -restarted. Failed sends are not replayed; disconnected input uses the bounded +invalid configuration, authentication/credit failures, unavailable explicit +routes, and any handshake rejection the Router marks non-retryable (for example +HTTP 400 on upgrade, or an `error` event before `session.ready` with +`retryable: false`) report one fatal error and stop further attempts until the +cause is corrected and the extension is restarted. Failed sends are not replayed; disconnected input uses the bounded buffer. Stop cancels pending recovery and finalization. Finalization waits for an in-progress handshake and previously accepted audio diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py index 96ca293a0d..28d84f27cb 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py @@ -42,6 +42,10 @@ def from_event(cls, event: dict[str, Any]) -> "SpekoRouterError": ) +class SpekoCommitTimeout(SpekoRouterError): + """input.commit got no transcript.final; the session itself is intact.""" + + def websocket_url(base_url: str, path: str) -> str: parsed = urlparse(base_url) scheme = {"https": "wss", "http": "ws"}.get(parsed.scheme, parsed.scheme) @@ -175,7 +179,7 @@ async def commit(self) -> None: asyncio.shield(waiter), timeout=self.finalize_timeout_sec ) except asyncio.TimeoutError as error: - raise SpekoRouterError( + raise SpekoCommitTimeout( "request_timeout", "Timed out waiting for a final Speko transcript", retryable=True, diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py index 305e249b5a..806f9a4a1c 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py @@ -7,6 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from ten_ai_base import utils +# Audio is buffered in memory while the Router session is not ready. +MAX_BUFFER_DURATION_MS = 30_000 + class SpekoASRConfig(BaseModel): model_config = ConfigDict(validate_assignment=True) @@ -45,6 +48,16 @@ def validate_channels(cls, value: int) -> int: raise ValueError("channels must be between 1 and 8") return value + @field_validator("buffer_duration_ms") + @classmethod + def validate_buffer_duration_ms(cls, value: int) -> int: + if not 0 <= value <= MAX_BUFFER_DURATION_MS: + raise ValueError( + "buffer_duration_ms must be between 0 and " + f"{MAX_BUFFER_DURATION_MS}" + ) + return value + def update_params(self) -> None: values = dict(self.params) for name in ( @@ -68,8 +81,6 @@ def _validate_required(self) -> None: if not self.api_key.strip(): raise ValueError("api_key is required") validate_base_url(self.base_url) - if self.buffer_duration_ms < 0: - raise ValueError("buffer_duration_ms must not be negative") if not re.fullmatch( r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", self.language ): diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py index 02e50af731..40275b537b 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -29,9 +29,21 @@ from ten_runtime import AsyncTenEnv, AudioFrame, Data from typing_extensions import override -from .client import SpekoASRClient, SpekoRouterError +from .client import SpekoASRClient, SpekoCommitTimeout, SpekoRouterError from .config import SpekoASRConfig, safe_error, safe_url +# Disconnected audio stays in memory; bound it for high-rate input. +MAX_BUFFER_BYTES = 10 * 1024 * 1024 +FATAL_ROUTER_CODES = frozenset( + { + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + "retry_exhausted", + } +) + class SpekoASRExtension(AsyncASRBaseExtension): RECONNECT_DELAYS = (0.5, 1.0, 2.0, 4.0, 4.0) @@ -178,7 +190,12 @@ async def on_disconnect(error: SpekoRouterError | None) -> None: await self._close_retired_clients() raise except Exception as error: - await self._reset_connection(self._router_error(error), epoch) + await self._reset_connection( + self._router_error(error), epoch, setup=True + ) + # The failed handshake has no live socket; release it now so + # a fatal setup error leaves nothing for a cleanup task. + await self._close_retired_clients() def _detach_client(self) -> None: self._connection_epoch += 1 @@ -268,13 +285,15 @@ def input_audio_channels(self) -> int: def buffer_strategy(self) -> ASRBufferConfig: if self.config is None or self.config.buffer_duration_ms == 0: return ASRBufferConfigModeDiscard() + # Overflow drops the oldest frames, recorded as dropped audio. return ASRBufferConfigModeKeep( - byte_limit=( + byte_limit=min( + MAX_BUFFER_BYTES, self.input_audio_sample_rate() * self.input_audio_channels() * self.input_audio_sample_width() * self.config.buffer_duration_ms - // 1000 + // 1000, ) ) @@ -374,7 +393,9 @@ async def _reconnect(self) -> None: self._permanent_error = SpekoRouterError( "retry_exhausted", "Speko ASR reconnect attempts exhausted" ) - await self._report_router_error(self._permanent_error) + await self._report_router_error( + self._permanent_error, ModuleErrorCode.FATAL_ERROR + ) @override async def send_audio( @@ -456,6 +477,15 @@ async def finalize(self, session_id: str | None) -> None: self._commit_task = asyncio.create_task(client.commit()) try: await self._commit_task + except SpekoCommitTimeout as timeout: + # The provider may have finalized this turn on its own, so + # the Router has no final left to send. The session is + # still usable: complete the finalize instead of failing. + self.ten_env.log_warn( + "Speko ASR commit was not acknowledged: " + f"{safe_error(timeout)}", + category=LOG_CATEGORY_VENDOR, + ) finally: self._commit_task = None if epoch == self._connection_epoch: @@ -486,7 +516,11 @@ async def finalize(self, session_id: str | None) -> None: self.metadata = previous async def _reset_connection( - self, error: SpekoRouterError, epoch: int | None = None + self, + error: SpekoRouterError, + epoch: int | None = None, + *, + setup: bool = False, ) -> None: if self.stopped or ( epoch is not None and epoch != self._connection_epoch @@ -495,14 +529,20 @@ async def _reset_connection( # Invalidate before the first await. Error + close callbacks for the # same generation now have exactly one owner and one error report. self._detach_client() - if self._module_error_code(error) == ModuleErrorCode.FATAL_ERROR: + # One classification drives the latch and every report. + code = self._module_error_code(error, setup=setup) + fatal = code == ModuleErrorCode.FATAL_ERROR + if fatal: self._permanent_error = error - await self._report_router_error(error) + await self._report_router_error(error, code) await self.on_disconnected( - code=self._module_error_code(error).value, + code=code.value, message=safe_error(error), vendor_info=self._vendor_info(error), ) + if fatal and setup: + # start_connection releases the failed handshake itself. + return self._schedule_reconnect() async def _on_router_disconnect( @@ -631,7 +671,9 @@ async def _send_transcript( if final: self._final_cursor_ms = max(self._final_cursor_ms, end_ms) - async def _report_router_error(self, error: SpekoRouterError) -> None: + async def _report_router_error( + self, error: SpekoRouterError, code: ModuleErrorCode + ) -> None: self.ten_env.log_error( f"vendor_error: code={error.code}, message={safe_error(error)}", category=LOG_CATEGORY_VENDOR, @@ -639,7 +681,7 @@ async def _report_router_error(self, error: SpekoRouterError) -> None: await self.send_asr_error( ModuleError( module="asr", - code=self._module_error_code(error).value, + code=code.value, message=safe_error(error), vendor_info=self._vendor_info(error), ) @@ -695,14 +737,12 @@ def _vendor_audio_duration(self) -> int: ) @staticmethod - def _module_error_code(error: SpekoRouterError) -> ModuleErrorCode: - if error.code in { - "authentication_failed", - "insufficient_credit", - "route_not_found", - "capability_unsupported", - "retry_exhausted", - }: + def _module_error_code( + error: SpekoRouterError, *, setup: bool = False + ) -> ModuleErrorCode: + # A handshake/configuration rejection the Router marks non-retryable + # would fail the same way on every reconnect. + if error.code in FATAL_ROUTER_CODES or (setup and not error.retryable): return ModuleErrorCode.FATAL_ERROR return ModuleErrorCode.NON_FATAL_ERROR diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py index 5feb48e6ec..da7487b921 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py @@ -5,6 +5,7 @@ import pytest from speko_asr_python.client import ( SpekoASRClient, + SpekoCommitTimeout, SpekoRouterError, websocket_url, ) @@ -172,9 +173,11 @@ async def test_timeout_does_not_poison_next_commit(): ): await client.connect() await client.send_audio(b"\x00\x00") - with pytest.raises(SpekoRouterError, match="Timed out"): + with pytest.raises(SpekoCommitTimeout, match="Timed out"): await client.commit() assert client._finalize_waiter is None + # A missing acknowledgement is not a transport failure. + assert client.is_ready next_commit = asyncio.create_task(client.commit()) await asyncio.sleep(0) websocket.messages.put_nowait( @@ -321,3 +324,70 @@ async def test_commit_without_audio_completes_without_protocol_wait(): await asyncio.wait_for(client.commit(), 0.1) assert len(websocket.sent) == 1 await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_empty_final_acknowledges_commit(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + events = [] + client = make_client(events, []) + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + await client.connect() + await client.send_audio(b"\x00\x00") + commit = asyncio.create_task(client.commit()) + await asyncio.sleep(0) + websocket.messages.put_nowait( + json.dumps({"type": "transcript.final", "text": ""}) + ) + await asyncio.wait_for(commit, 0.5) + assert events[-1] == {"type": "transcript.final", "text": ""} + assert client.is_ready + await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_upgrade_400_without_envelope_is_not_retryable(): + from unittest.mock import MagicMock + from websockets.exceptions import InvalidStatus + + response = MagicMock(status_code=400, reason_phrase="bad", body=b"") + client = make_client([], []) + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(side_effect=InvalidStatus(response)), + ): + with pytest.raises(SpekoRouterError) as caught: + await client.connect() + assert caught.value.code == "invalid_request" + assert caught.value.retryable is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("retryable", [False, True]) +async def test_error_before_ready_keeps_router_retryable_flag(retryable): + websocket = FakeWebSocket( + json.dumps( + { + "type": "error", + "error": { + "code": "invalid_request", + "message": "unsupported language", + "retryable": retryable, + }, + } + ) + ) + client = make_client([], []) + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + with pytest.raises(SpekoRouterError) as caught: + await client.connect() + assert caught.value.code == "invalid_request" + assert caught.value.retryable is retryable + assert websocket.closed + assert not client.is_ready diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py index daec36e185..a51df95626 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py @@ -4,7 +4,7 @@ import pytest -from speko_asr_python.client import SpekoRouterError +from speko_asr_python.client import SpekoCommitTimeout, SpekoRouterError from speko_asr_python.config import SpekoASRConfig from speko_asr_python.extension import SpekoASRExtension @@ -58,15 +58,39 @@ async def test_multiple_turns_have_separate_duration_and_session_positions(): @pytest.mark.asyncio -async def test_timeout_disconnects_and_completes_finalize_once(): +async def test_commit_timeout_keeps_session_and_completes_finalize_once(): extension, client = make_extension() - client.commit.side_effect = SpekoRouterError( + assert await extension.send_audio(frame(), "session") + client.commit.side_effect = SpekoCommitTimeout( "request_timeout", "timeout", retryable=True ) await extension.finalize("session") + assert extension.client is client + client.close.assert_not_awaited() + extension.send_asr_error.assert_not_awaited() + extension.on_disconnected.assert_not_awaited() + assert extension._reconnect_task is None + extension.ten_env.log_warn.assert_called_once() + extension.send_asr_finalize_end.assert_awaited_once() + # Bookkeeping advances exactly as for an acknowledged commit. + assert extension._total_audio_bytes == 0 + assert extension._final_cursor_ms == 1000 + assert extension._commit_boundary_ms == 1000 + + +@pytest.mark.asyncio +async def test_disconnect_during_commit_resets_and_completes_finalize_once(): + extension, client = make_extension() + client.commit.side_effect = SpekoRouterError( + "relay_error", + "Speko ASR session closed before final transcript", + retryable=True, + ) + await extension.finalize("session") await extension.stop_connection() - client.close.assert_awaited_once() + client.close.assert_awaited() assert extension.client is None + extension.send_asr_error.assert_awaited_once() extension.send_asr_finalize_end.assert_awaited_once() @@ -261,3 +285,41 @@ async def reconnect(): release.set() await extension._reconnect_task extension.start_connection.assert_awaited_once() + + +def test_buffer_duration_bounds(): + config = SpekoASRConfig( + api_key="test-key", params={"buffer_duration_ms": 30000} + ) + config.update_params() + assert config.buffer_duration_ms == 30000 + for value in (30001, -1): + with pytest.raises(ValueError): + SpekoASRConfig( + api_key="test-key", params={"buffer_duration_ms": value} + ).update_params() + with pytest.raises(ValueError): + SpekoASRConfig(api_key="test-key", buffer_duration_ms=value) + + +@pytest.mark.parametrize( + "sample_rate,channels,duration_ms,expected", + [ + (16000, 1, 30000, 960000), + (48000, 2, 30000, 5760000), + # 48 kHz x 8 ch x 30 s would be 23 MB; the in-memory cap binds. + (48000, 8, 30000, 10 * 1024 * 1024), + (192000, 8, 30000, 10 * 1024 * 1024), + ], +) +def test_buffer_byte_limit_is_capped( + sample_rate, channels, duration_ms, expected +): + from speko_asr_python.extension import MAX_BUFFER_BYTES + + extension, _ = make_extension() + extension.config.sample_rate = sample_rate + extension.config.channels = channels + extension.config.buffer_duration_ms = duration_ms + assert MAX_BUFFER_BYTES == 10 * 1024 * 1024 + assert extension.buffer_strategy().byte_limit == expected diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py index 989463529e..63a878b98e 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py @@ -9,7 +9,7 @@ from ten_ai_base.message import ModuleErrorCode from ten_runtime import Data -from speko_asr_python.client import SpekoRouterError +from speko_asr_python.client import SpekoCommitTimeout, SpekoRouterError from speko_asr_python.config import SpekoASRConfig from speko_asr_python.extension import SpekoASRExtension @@ -140,14 +140,22 @@ async def commit(): @pytest.mark.asyncio @pytest.mark.parametrize( - "failure", + "failure,errors", [ - None, - SpekoRouterError("request_timeout", "timeout", retryable=True), - OSError("closed"), + (None, 0), + (SpekoCommitTimeout("request_timeout", "timeout", retryable=True), 0), + ( + SpekoRouterError( + "relay_error", + "Speko ASR session closed before final transcript", + retryable=True, + ), + 1, + ), + (OSError("closed"), 1), ], ) -async def test_finalize_acknowledges_disconnection_and_errors(failure): +async def test_finalize_acknowledges_disconnection_and_errors(failure, errors): ext, events = extension_and_events() if failure is None: ext.client = None @@ -161,6 +169,150 @@ async def test_finalize_acknowledges_disconnection_and_errors(failure): "metadata": {"session_id": "final-session", "custom": "id"}, } ] + assert len(payloads(events, "error")) == errors + + +@pytest.mark.asyncio +async def test_commit_timeout_keeps_session_and_next_turn_timeline(): + ext, events = extension_and_events() + client = ext.client + await ext._handle_audio_frame(ext.ten_env, audio()) + client.commit.side_effect = SpekoCommitTimeout( + "request_timeout", + "Timed out waiting for a final Speko transcript", + retryable=True, + ) + await ext.on_data(ext.ten_env, finalize_data("one", "turn-one")) + assert ext.client is client and ext.is_connected() + assert ext._reconnect_task is None + client.close.assert_not_awaited() + assert not payloads(events, "error") + assert not payloads(events, "connection_status_changed") + assert [ + item["finalize_id"] for item in payloads(events, "asr_finalize_end") + ] == ["one"] + warnings = [call.args[0] for call in ext.ten_env.log_warn.call_args_list] + assert any("not acknowledged" in message for message in warnings) + # The next turn starts where the timed-out finalize closed the last one. + client.commit.side_effect = None + await ext._handle_audio_frame(ext.ten_env, audio()) + await ext._on_router_event({"type": "transcript.final", "text": "next"}) + await ext.on_data(ext.ten_env, finalize_data("two", "turn-two")) + results = payloads(events, "asr_result") + assert [(item["start_ms"], item["duration_ms"]) for item in results] == [ + (1000, 1000) + ] + assert len(payloads(events, "asr_finalize_end")) == 2 + await ext.on_stop(ext.ten_env) + + +@pytest.mark.asyncio +async def test_empty_final_acknowledges_finalize_without_a_result(): + ext, events = extension_and_events() + await ext._handle_audio_frame(ext.ten_env, audio()) + + async def commit(): + await ext._on_router_event({"type": "transcript.final", "text": ""}) + + ext.client.commit.side_effect = commit + await ext.on_data(ext.ten_env, finalize_data("empty", "turn")) + assert not payloads(events, "asr_result") + assert not payloads(events, "error") + assert [ + item["finalize_id"] for item in payloads(events, "asr_finalize_end") + ] == ["empty"] + assert ext._final_cursor_ms == 1000 + await ext.on_stop(ext.ten_env) + + +def _upgrade_rejected(status): + from websockets.exceptions import InvalidStatus + + response = MagicMock(status_code=status, reason_phrase="denied", body=b"") + return AsyncMock(side_effect=InvalidStatus(response)) + + +class _ErrorBeforeReady: + """A Router socket that rejects session.configure with an error event.""" + + def __init__(self, retryable): + self.retryable = retryable + self.closed = False + + async def send(self, _value): + pass + + async def recv(self): + return json.dumps( + { + "type": "error", + "error": { + "code": "invalid_request", + "message": "unsupported language", + "retryable": self.retryable, + }, + } + ) + + async def close(self): + self.closed = True + + +def _error_before_ready(retryable): + return AsyncMock(return_value=_ErrorBeforeReady(retryable)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "connect,router_code", + [ + (_upgrade_rejected(400), "invalid_request"), + (_error_before_ready(False), "invalid_request"), + ], +) +async def test_non_retryable_setup_failure_is_one_fatal_error_without_retry( + connect, router_code +): + ext, events = extension_and_events() + ext.client = None + ext.ten_env.get_property_bool = AsyncMock(return_value=(True, None)) + ext.ten_env.get_property_to_json = AsyncMock( + return_value=(json.dumps({"params": {"api_key": "key"}}), None) + ) + with patch("speko_asr_python.client.websockets.connect", connect): + await ext.on_init(ext.ten_env) + await ext.on_start(ext.ten_env) + await ext._ensure_connection() + for _ in range(3): + await ext.on_audio_frame(ext.ten_env, audio()) + await ext.on_data(ext.ten_env, Data.create("trigger_connect")) + await asyncio.sleep(0) + assert ext._reconnect_task is None + assert connect.await_count == 1 + await ext.on_stop(ext.ten_env) + errors = payloads(events, "error") + assert len(errors) == 1 + assert errors[0]["code"] == int(ModuleErrorCode.FATAL_ERROR.value) + assert errors[0]["vendor_info"]["code"] == router_code + assert ext._permanent_error is not None + assert not ext._retired_clients + + +@pytest.mark.asyncio +async def test_retryable_setup_failure_reports_non_fatal_and_reconnects(): + ext, events = extension_and_events() + ext.client = None + ext.config = SpekoASRConfig(api_key="key") + with patch( + "speko_asr_python.client.websockets.connect", _upgrade_rejected(503) + ): + await ext.start_connection() + errors = payloads(events, "error") + assert len(errors) == 1 + assert errors[0]["code"] == int(ModuleErrorCode.NON_FATAL_ERROR.value) + assert ext._permanent_error is None + assert ext._reconnect_task is not None + await ext.stop_connection() @pytest.mark.asyncio @@ -218,17 +370,21 @@ async def connect(): @pytest.mark.asyncio -async def test_invalid_configuration_is_latched_before_any_connect_attempt(): +@pytest.mark.parametrize( + "params", + [ + {"api_key": "private-value", "base_url": "invalid"}, + {"api_key": "private-value", "buffer_duration_ms": 30001}, + ], +) +async def test_invalid_configuration_is_latched_before_any_connect_attempt( + params, +): ext, events = extension_and_events() ext.client = None ext.ten_env.get_property_bool = AsyncMock(return_value=(True, None)) ext.ten_env.get_property_to_json = AsyncMock( - return_value=( - json.dumps( - {"params": {"api_key": "private-value", "base_url": "invalid"}} - ), - None, - ) + return_value=(json.dumps({"params": params}), None) ) # The real base initializer starts a consumer; stop it through the lifecycle. with patch("speko_asr_python.extension.SpekoASRClient") as constructor: @@ -406,6 +562,22 @@ async def test_dropped_audio_and_fractional_frames_have_correct_timeline(): assert ext.audio_timeline.get_audio_duration_before_time(500) == 520 +@pytest.mark.asyncio +async def test_disconnected_buffer_cap_drops_oldest_frames_into_timeline(): + ext, _ = extension_and_events() + ext.client.is_ready = False + ext.config.sample_rate = 48000 + ext.config.channels = 8 + ext.config.buffer_duration_ms = 30000 + second = 48000 * 8 * 2 # 768000 bytes is one second at this format. + for _ in range(14): + await ext._handle_audio_frame(ext.ten_env, audio(size=second)) + # 14 s exceed the 10 MiB cap; the oldest second is dropped and recorded. + assert ext.buffered_frames.qsize() == 13 + assert ext.buffered_frames_size == 13 * second <= 10 * 1024 * 1024 + assert ext.audio_timeline.total_dropped_audio_duration == 1000 + + @pytest.mark.parametrize( "params", [ @@ -418,6 +590,8 @@ async def test_dropped_audio_and_fractional_frames_have_correct_timeline(): {"sample_rate": 1}, {"channels": 0}, {"routing": {"mode": "wrong"}}, + {"buffer_duration_ms": 30001}, + {"buffer_duration_ms": -1}, ], ) def test_invalid_config_values(params): From a9e7e595b0d4d121c3c19d67d6dbde717ff4be38 Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 00:10:33 +0500 Subject: [PATCH 07/17] fix(speko_llm2_python): report malformed router responses as ten errors - Wrap JSON decode failures of non-streaming responses in SpekoRouterError. - Accept {"error": "text"} envelopes over HTTP and SSE. - Backstop: any other request-path failure, including an invalid TEN request, is emitted as one TEN error with vendor_info before the request ends. - on_stop cancels in-flight streams and closes the client before the base stops, and never raises. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_llm2_python/README.md | 4 + .../extension/speko_llm2_python/client.py | 14 ++- .../extension/speko_llm2_python/extension.py | 44 +++++-- .../speko_llm2_python/tests/test_client.py | 77 ++++++++++++ .../speko_llm2_python/tests/test_extension.py | 113 ++++++++++++++++-- 5 files changed, 232 insertions(+), 20 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md b/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md index 5226c802af..f2cd9c52db 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md @@ -39,6 +39,10 @@ configured in explicit mode. Speko's launch Responses contract accepts text content only. Image content is rejected locally with a clear error instead of being silently dropped. +Every request failure, including an invalid request, malformed Router JSON, or +an `{"error": "..."}` envelope over HTTP or SSE, is emitted as one TEN `error` +with `vendor_info.vendor` set to `speko` before the request ends. + ## Testing From `ai_agents/`: diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py index 3015f161a5..061977c9f4 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py @@ -28,6 +28,11 @@ def from_envelope( cls, envelope: dict[str, Any], *, status_code: int = 0 ) -> "SpekoRouterError": error = envelope.get("error", {}) + if isinstance(error, str) and error: + # Relays and proxies may send {"error": "message"}. + error = {"message": error} + if not isinstance(error, dict): + error = {} return cls( str(error.get("code", "relay_error")), str(error.get("message", "Speko Router error")), @@ -167,7 +172,14 @@ async def complete( ) from error if response.status_code >= 400: raise await self._error_from_response(response) - decoded = response.json() + try: + decoded = response.json() + except ValueError as error: + raise SpekoRouterError( + "relay_error", + "Speko Router returned invalid JSON", + status_code=response.status_code, + ) from error if not isinstance(decoded, dict): raise SpekoRouterError( "relay_error", "Speko Router response must be a JSON object" diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py index 5bdcba63dc..b1f3a54b2e 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py @@ -79,12 +79,19 @@ async def on_start(self, ten_env: AsyncTenEnv) -> None: ) async def on_stop(self, ten_env: AsyncTenEnv) -> None: + # Clean up before the base stops, like the ASR/TTS extensions. Cancel + # in-flight streams first so closing their transport is not reported + # as a request failure. An exception escaping on_stop aborts TEN. + await self._cancel_all() + client, self.client = self.client, None try: - await super().on_stop(ten_env) - finally: - client, self.client = self.client, None if client is not None: await client.close() + except Exception as error: + ten_env.log_warn( + f"Speko LLM client close failed: {safe_error(error)}" + ) + await super().on_stop(ten_env) async def on_retrieve_prompt( self, ten_env: AsyncTenEnv, request: LLMRequestRetrievePrompt @@ -109,19 +116,24 @@ async def _chat_completion( response_id = request.request_id full_content = "" ttft_ms: int | None = None - payload = self._build_payload(request) stream = request.streaming is not False - payload["stream"] = stream idempotency_key = uuid.uuid4().hex - ten_env.log_info( - "Requesting Speko LLM response: " - f"messages={len(request.messages)}, stream={stream}, " - f"routing={payload.get('routing', {})}", - category=LOG_CATEGORY_VENDOR, - ) - try: + try: + payload = self._build_payload(request) + except ValueError as error: + raise SpekoRouterError( + "invalid_request", safe_error(error) + ) from error + payload["stream"] = stream + ten_env.log_info( + "Requesting Speko LLM response: " + f"messages={len(request.messages)}, stream={stream}, " + f"routing={payload.get('routing', {})}", + category=LOG_CATEGORY_VENDOR, + ) + if not stream: response = await self.client.complete( payload, idempotency_key=idempotency_key @@ -197,6 +209,14 @@ async def _chat_completion( raise RuntimeError( f"Speko Router request failed ({error.code}): {safe_error(error)}" ) from error + except Exception as error: + # Backstop: a malformed payload the client did not anticipate must + # still reach the graph as a standard TEN error, not only a log. + router_error = SpekoRouterError("relay_error", safe_error(error)) + await self._report_router_error(router_error, request) + raise RuntimeError( + f"Speko Router request failed (relay_error): {safe_error(error)}" + ) from error def _build_payload(self, request: LLMRequest) -> dict[str, Any]: assert self.config is not None diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py index b506796b13..153e600b8f 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py @@ -141,3 +141,80 @@ async def handler(request): assert caught.value.code == "relay_error" assert caught.value.retryable is True await http_client.aclose() + + +def make_client(handler): + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = SpekoLLMClient( + api_key="test-key", + base_url="https://router.speko.dev", + timeout_sec=1, + http_client=http_client, + ) + return client, http_client + + +@pytest.mark.asyncio +async def test_malformed_success_json_is_router_error(): + client, http_client = make_client( + lambda request: httpx.Response(200, text="bad gateway") + ) + with pytest.raises(SpekoRouterError) as caught: + await client.complete({"input": []}, idempotency_key="idem-1") + assert caught.value.code == "relay_error" + assert caught.value.status_code == 200 + await http_client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_string_error_envelope_over_http(streaming): + client, http_client = make_client( + lambda request: httpx.Response(502, json={"error": "upstream down"}) + ) + with pytest.raises(SpekoRouterError) as caught: + if streaming: + _ = [ + event + async for event in client.stream( + {"input": []}, idempotency_key="idem-1" + ) + ] + else: + await client.complete({"input": []}, idempotency_key="idem-1") + assert caught.value.code == "relay_error" + assert caught.value.message == "upstream down" + assert caught.value.status_code == 502 + await http_client.aclose() + + +@pytest.mark.asyncio +async def test_string_error_envelope_over_sse(): + client, http_client = make_client( + lambda request: httpx.Response( + 200, + text=sse( + ("response.created", {"response_id": "resp_1"}), + ("error", {"error": "quota exceeded"}), + ), + ) + ) + with pytest.raises(SpekoRouterError) as caught: + _ = [ + event + async for event in client.stream( + {"input": []}, idempotency_key="idem-1" + ) + ] + assert caught.value.code == "relay_error" + assert caught.value.message == "quota exceeded" + await http_client.aclose() + + +@pytest.mark.parametrize( + "envelope", [{}, {"error": None}, {"error": 7}, {"error": ""}] +) +def test_envelope_without_error_object_uses_defaults(envelope): + error = SpekoRouterError.from_envelope(envelope) + assert error.code == "relay_error" + assert error.message == "Speko Router error" diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py index 0ccc1742a6..98bf1b9dd1 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py @@ -2,10 +2,12 @@ import json from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest +from ten_ai_base.message import ModuleErrorCode from ten_ai_base.struct import LLMRequest -from speko_llm2_python.client import SSEEvent +from speko_llm2_python.client import SpekoLLMClient, SSEEvent from speko_llm2_python.config import SpekoLLM2Config from speko_llm2_python.extension import SpekoLLM2Extension @@ -68,26 +70,123 @@ async def complete(payload, **_kwargs): @pytest.mark.asyncio -async def test_stop_cancels_base_before_closing_client_even_on_error(): +@pytest.mark.parametrize("close_fails", [False, True]) +async def test_stop_cancels_requests_and_closes_client_before_base( + close_fails, +): extension = SpekoLLM2Extension("speko") calls = [] - async def stop(_ten_env): + async def cancel_all(): calls.append("cancel") - raise RuntimeError("base failure") async def close(): calls.append("close") + if close_fails: + raise RuntimeError("close failure") + + async def stop(_ten_env): + calls.append("base") + extension._cancel_all = cancel_all extension.client = MagicMock(close=AsyncMock(side_effect=close)) + ten_env = MagicMock() with patch( "speko_llm2_python.extension.AsyncLLM2BaseExtension.on_stop", side_effect=stop, ): - with pytest.raises(RuntimeError): - await extension.on_stop(MagicMock()) - assert calls == ["cancel", "close"] + # An exception escaping on_stop would abort the TEN process. + await extension.on_stop(ten_env) + assert calls == ["cancel", "close", "base"] assert extension.client is None + assert ten_env.log_warn.called is close_fails + + +def _configured_extension(handler): + extension = SpekoLLM2Extension("speko") + extension.config = SpekoLLM2Config(api_key="key") + extension.config.validate_required() + extension.ten_env = MagicMock(send_data=AsyncMock()) + extension.client = SpekoLLMClient( + api_key="key", + base_url="https://router.speko.dev", + timeout_sec=1, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + return extension + + +def _sse(*events): + return "".join( + f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "streaming,response,router_code", + [ + # Malformed 2xx JSON. + (False, httpx.Response(200, text="not json"), "relay_error"), + # {"error": "..."} envelopes over HTTP and SSE. + (False, httpx.Response(503, json={"error": "busy"}), "relay_error"), + ( + True, + httpx.Response(200, text=_sse(("error", {"error": "busy"}))), + "relay_error", + ), + # A payload the client accepts but the extension cannot use. + ( + False, + httpx.Response( + 200, + json={ + "id": "response", + "output": [ + { + "type": "function_call", + "call_id": "call", + "name": "lookup", + "arguments": "{not json", + } + ], + }, + ), + "relay_error", + ), + # An invalid TEN request is reported, not only logged. + (False, None, "invalid_request"), + ], +) +async def test_failures_emit_ten_error_with_speko_vendor_info( + streaming, response, router_code +): + extension = _configured_extension(lambda request: response) + request = LLMRequest( + request_id="request", + messages=[], + streaming=streaming, + model="bare-model" if response is None else None, + ) + with pytest.raises(RuntimeError): + _ = [ + item + async for item in extension.on_call_chat_completion( + extension.ten_env, request + ) + ] + sent = [ + call.args[0] for call in extension.ten_env.send_data.await_args_list + ] + errors = [data for data in sent if data.get_name() == "error"] + assert len(errors) == 1 + raw, _ = errors[0].get_property_to_json("") + payload = json.loads(raw) + assert payload["id"] == "request" + assert payload["code"] == int(ModuleErrorCode.NON_FATAL_ERROR.value) + assert payload["vendor_info"]["vendor"] == "speko" + assert payload["vendor_info"]["code"] == router_code + await extension.client._client.aclose() def test_config_log_redacts_url(): From d7f20933f3be0dd23fb8842eb1238621b5b49c92 Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 00:10:33 +0500 Subject: [PATCH 08/17] fix(speko_tts2_python): stop safely and account audio end from emitted frames - on_stop closes the session without draining and logs cleanup failures; an exception escaping on_stop made TEN exit the process (guarder test_invalid_text_handling). - The close drain treats ConnectionClosed as closed and has a total bound. - Whitespace-only chunks are not sent to the Router. - tts_audio_end duration counts bytes emitted as frames and its interval runs from the request's tts_audio_start; a final-chunk error reports one disconnect; a close failure during shutdown never finishes as success. - TTFB starts right before the input.append send and is rounded. - tests/test_audio_accounting.py drives known PCM through the real base. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_tts2_python/README.md | 9 +- .../extension/speko_tts2_python/client.py | 31 +- .../extension/speko_tts2_python/extension.py | 75 ++-- .../tests/test_audio_accounting.py | 366 ++++++++++++++++++ .../speko_tts2_python/tests/test_extension.py | 173 ++++++++- 5 files changed, 609 insertions(+), 45 deletions(-) create mode 100644 ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md index 7d232ce63f..d1032766e5 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md @@ -64,13 +64,20 @@ installed guarder’s `EXT_NAME=speko_tts2_python tests/bin/start` with and `--config_dir /absolute/path/to/configs`. Keep invalid-key configs invalid. Client/lifecycle unit tests use mocked transport and do not require credentials. -Text chunks retain their whitespace. An intermediate-chunk error resets the +Text chunks retain their whitespace; a chunk that is only whitespace is not +sent to the Router. An intermediate-chunk error resets the connection while retaining the TEN request until its final input. Failed text is never replayed; later text can continue unless a permanent admission denial blocks the session. A terminal close error produces an error completion and still releases the request. Non-retryable denials are not automatically retried. +Each request emits exactly one `tts_audio_end`. Its duration counts the PCM +bytes actually emitted as frames, and its interval runs from that request's +`tts_audio_start`. TTFB runs from just before the text is sent to the first +audio frame. Stopping the extension closes the Router session without waiting +for final usage, and cleanup failures during stop are logged, not raised. + Standalone regression gate from `ai_agents/`: ```bash diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py index afa404f2a7..45f9cf5a01 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py @@ -39,6 +39,10 @@ def from_event(cls, event: dict[str, Any]) -> "SpekoRouterError": ) +# A close drain only collects final usage; never let it hold up shutdown. +DRAIN_TIMEOUT_SEC = 5.0 + + class SpekoTTSEventType(str, Enum): AUDIO = "audio" TTFB = "ttfb" @@ -152,14 +156,13 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: ) websocket = self._ws - await websocket.send( - json.dumps( - {"type": "input.append", "text": text}, - separators=(",", ":"), - ) + append = json.dumps( + {"type": "input.append", "text": text}, separators=(",", ":") ) - await websocket.send('{"type":"input.commit"}') + # TTFB starts when the text leaves, not after the commit frame. sent_at = time.monotonic() + await websocket.send(append) + await websocket.send('{"type":"input.commit"}') sequence: int | None = None first_audio = True @@ -192,7 +195,7 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: first_audio = False yield SpekoTTSEvent( SpekoTTSEventType.TTFB, - int((time.monotonic() - sent_at) * 1000), + round((time.monotonic() - sent_at) * 1000), ) if self._cancelled: return @@ -251,9 +254,18 @@ async def close(self, *, drain: bool = True) -> None: async def _drain_close(self) -> None: websocket = self._ws assert websocket is not None + loop = asyncio.get_running_loop() + # Bound the whole drain: audio still streaming after session.close + # would otherwise renew a per-frame timeout indefinitely. + deadline = loop.time() + DRAIN_TIMEOUT_SEC try: while not self._cancelled: - raw = await asyncio.wait_for(websocket.recv(), timeout=2.0) + remaining = deadline - loop.time() + if remaining <= 0: + return + raw = await asyncio.wait_for( + websocket.recv(), timeout=min(2.0, remaining) + ) if isinstance(raw, bytes): continue event = self._decode_event(raw) @@ -264,7 +276,8 @@ async def _drain_close(self) -> None: raise SpekoRouterError.from_event(event) if event_type == "session.closed": return - except asyncio.TimeoutError: + except (asyncio.TimeoutError, ConnectionClosed): + # The Router closing first is a completed close. return async def _close_socket(self) -> None: diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py index f7531f05f8..9a2098ddab 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py @@ -38,7 +38,9 @@ def __init__(self, name: str) -> None: self._route: dict[str, Any] = {} self._router_usage: dict[str, Any] = {} self._audio_start_sent = False - self._first_audio_at: float | None = None + self._audio_start_at: float | None = None + # PCM bytes actually emitted as frames, excluding any odd tail the + # base keeps in leftover_bytes. self._total_audio_bytes = 0 self._finalized = False self._audio_end_sent = False @@ -76,19 +78,26 @@ async def on_init(self, ten_env: AsyncTenEnv) -> None: ) async def on_stop(self, ten_env: AsyncTenEnv) -> None: + # TEN aborts the process when an exception escapes on_stop, so + # cleanup failures are logged, never raised. self._stopped = True task = self.current_task await self._cancel_current_task() if task is not None and task is not asyncio.current_task(): await asyncio.gather(task, return_exceptions=True) try: - await self._close_client() - finally: + # Usage drained here would have no request left to report on. + await self._close_client(drain=False) + except Exception as error: + ten_env.log_warn(f"Speko TTS close on stop: {safe_error(error)}") + for request_id in list(self._recorders): try: - for request_id in list(self._recorders): - await self._flush_recorder(request_id) - finally: - await super().on_stop(ten_env) + await self._flush_recorder(request_id) + except Exception as error: + ten_env.log_warn( + f"Speko TTS dump flush on stop: {safe_error(error)}" + ) + await super().on_stop(ten_env) def vendor(self) -> str: return "speko" @@ -140,7 +149,8 @@ async def _request_tts(self, text_input: TTSTextInput) -> None: if self._permanent_error is not None: raise self._permanent_error text = text_input.text - if text: + # Whitespace has nothing to synthesize; providers reject it. + if text.strip(): await self._stream_text(text, text_input.request_id) if text_input.text_input_end: @@ -210,8 +220,10 @@ async def _begin_request(self, text_input: TTSTextInput) -> None: self._route = {} self._router_usage = {} self._audio_start_sent = False - self._first_audio_at = None + self._audio_start_at = None self._total_audio_bytes = 0 + # An odd tail of the previous request is not this request's audio. + self.leftover_bytes = b"" self._finalized = False self._audio_end_sent = False self._cancelled = False @@ -266,12 +278,15 @@ async def _stream_text(self, text: str, request_id: str) -> None: if not isinstance(audio, bytes) or not audio: continue await self._ensure_audio_start() - if self._first_audio_at is None: - self._first_audio_at = time.monotonic() - self._total_audio_bytes += len(audio) self.metrics_add_recv_audio_chunks(audio) await self._write_dump(request_id, audio) + # The base emits whole frames and keeps an odd tail for the + # next chunk; count only bytes that left as frames. + pending = len(self.leftover_bytes) await self.send_tts_audio_data(audio) + self._total_audio_bytes += ( + pending + len(audio) - len(self.leftover_bytes) + ) elif event.type == SpekoTTSEventType.USAGE: if isinstance(event.value, dict): self._router_usage = event.value @@ -291,6 +306,8 @@ async def _finalize_request( await self._close_client() except Exception as close_error: if self._cancelled or self._stopped: + # Flush/shutdown own the outcome; never finish as success. + reason = TTSAudioEndReason.INTERRUPTED return router_error = ( close_error @@ -351,25 +368,29 @@ async def _handle_router_error( self, error: SpekoRouterError, final_input: bool ) -> None: module_error = self._make_module_error(error) + # The failed session is unusable. Detach it first so finalization + # does not close it again and report a second disconnect. + client, self.client = self.client, None + if client is not None: + self._router_usage = client.usage or self._router_usage + try: + await client.close(drain=False) + except Exception as close_error: + self.ten_env.log_debug( + f"Failed Speko TTS session close: {safe_error(close_error)}" + ) await self.on_disconnected( code=module_error.code, message=safe_error(error), vendor_info=module_error.vendor_info, ) - if self._finalized: - await self.send_tts_error(self.current_request_id, module_error) - elif final_input: + if final_input and not self._finalized: await self._finalize_request( TTSAudioEndReason.ERROR, error=module_error ) else: - # The remaining chunks still belong to the same TEN request. - client, self.client = self.client, None - try: - if client is not None: - await client.close(drain=False) - finally: - await self.send_tts_error(self.current_request_id, module_error) + # A non-final chunk leaves the rest of the TEN request open. + await self.send_tts_error(self.current_request_id, module_error) async def _ensure_audio_start(self) -> None: if self._audio_start_sent or self.current_request_id is None: @@ -379,14 +400,16 @@ async def _ensure_audio_start(self) -> None: extra_metadata={"route": self._route}, ) self._audio_start_sent = True + # The end event's interval is measured from this start event. + self._audio_start_at = time.monotonic() - async def _close_client(self) -> None: + async def _close_client(self, *, drain: bool = True) -> None: if self.client is None: return client, self.client = self.client, None request_id = self.current_request_id try: - await client.close() + await client.close(drain=drain) finally: if self.current_request_id == request_id: self._router_usage = client.usage or self._router_usage @@ -431,9 +454,9 @@ async def _flush_recorder(self, request_id: str) -> None: await recorder.flush() def _request_interval_ms(self) -> int: - if self._first_audio_at is None: + if self._audio_start_at is None: return 0 - return int((time.monotonic() - self._first_audio_at) * 1000) + return int((time.monotonic() - self._audio_start_at) * 1000) def _audio_duration_ms(self) -> int: bytes_per_second = ( diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py new file mode 100644 index 0000000000..362e050129 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py @@ -0,0 +1,366 @@ +"""Drive known PCM through the real TTS2 base and check audio-end accounting. + +Only the Router transport and the extension's clock are replaced. Requests, +flushes, frames, metrics and errors go through the base class unchanged. +""" + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from ten_ai_base.message import ModuleErrorCode, TTSAudioEndReason +from ten_ai_base.struct import TTSTextInput +from ten_runtime import Data + +from speko_tts2_python.client import ( + SpekoRouterError, + SpekoTTSEvent, + SpekoTTSEventType, +) +from speko_tts2_python.config import SpekoTTS2Config +from speko_tts2_python.extension import SpekoTTS2Extension + +# 24 kHz mono 16-bit PCM is 48 bytes per millisecond. +HUNDRED_MS = 4800 + + +class FakeClock: + """Advances only in binary-exact steps (125 ms) to keep ms arithmetic exact.""" + + def __init__(self): + self.now = 1000.0 + + def monotonic(self): + return self.now + + def time(self): + return self.now + + def advance(self, ms): + self.now += ms / 1000 + + +class RecordingTenEnv: + """Records what the real base methods emit, in emission order.""" + + def __init__(self): + self.events = [] + self.send_data = AsyncMock(side_effect=self._data) + self.send_audio_frame = AsyncMock(side_effect=self._frame) + for name in ("log_debug", "log_info", "log_warn", "log_error"): + setattr(self, name, MagicMock()) + + async def _data(self, data): + raw, _ = data.get_property_to_json("") + self.events.append((data.get_name(), json.loads(raw))) + + async def _frame(self, frame): + buffer = frame.lock_buf() + try: + self.events.append(("frame", len(buffer))) + finally: + frame.unlock_buf(buffer) + + def named(self, name): + return [payload for kind, payload in self.events if kind == name] + + def index(self, predicate): + return next( + i for i, event in enumerate(self.events) if predicate(*event) + ) + + +class Script: + """Steps for successive stream_text calls, shared by every client.""" + + def __init__(self, clock, *streams): + self.clock = clock + self.streams = list(streams) + self.blocked = asyncio.Event() + self.probes = [] + self.extension = None + + def client(self, **_kwargs): + return ScriptedClient(self) + + +class ScriptedClient: + def __init__(self, script): + self.script = script + self.route = {"provider": "test", "model": "voice"} + self.usage = {} + self.request_id = "router-request" + self.is_ready = False + + async def connect(self): + self.is_ready = True + + async def stream_text(self, _text): + for step, *args in self.script.streams.pop(0): + if step == "advance": + self.script.clock.advance(args[0]) + elif step == "ttfb": + yield SpekoTTSEvent(SpekoTTSEventType.TTFB, args[0]) + elif step == "audio": + yield SpekoTTSEvent(SpekoTTSEventType.AUDIO, b"\x01" * args[0]) + elif step == "usage": + self.usage = args[0] + yield SpekoTTSEvent(SpekoTTSEventType.USAGE, args[0]) + elif step == "probe": + self.script.probes.append( + self.script.extension._total_audio_bytes + ) + elif step == "error": + self.is_ready = False + raise args[0] + elif step == "block": + self.script.blocked.set() + await asyncio.Future() + yield SpekoTTSEvent(SpekoTTSEventType.DONE) + + async def close(self, *, drain=True): + del drain + self.is_ready = False + + async def cancel(self): + self.is_ready = False + + +def text_input(text, end, request_id="request"): + data = Data.create("tts_text_input") + data.set_property_from_json( + None, + TTSTextInput( + request_id=request_id, + text=text, + text_input_end=end, + metadata={"session_id": "session"}, + ).model_dump_json(), + ) + return data + + +def flush(): + data = Data.create("tts_flush") + data.set_property_from_json(None, json.dumps({"flush_id": "flush"})) + return data + + +async def until(predicate): + async def poll(): + while not predicate(): + await asyncio.sleep(0.001) + + await asyncio.wait_for(poll(), 2) + + +class Harness: + def __init__(self, *streams): + self.clock = FakeClock() + self.ten_env = RecordingTenEnv() + self.script = Script(self.clock, *streams) + self.extension = SpekoTTS2Extension("speko") + self.extension.config = SpekoTTS2Config(api_key="test-key") + self.extension.ten_env = self.ten_env + self.script.extension = self.extension + self._patches = [ + patch("speko_tts2_python.extension.time", self.clock), + patch( + "speko_tts2_python.extension.SpekoTTSClient", + side_effect=self.script.client, + ), + ] + + async def __aenter__(self): + for item in self._patches: + item.start() + self.extension.loop_task = asyncio.create_task( + self.extension._process_input_queue(self.ten_env) + ) + return self + + async def __aexit__(self, *_exc): + self.extension.loop_task.cancel() + await asyncio.gather(self.extension.loop_task, return_exceptions=True) + for item in self._patches: + item.stop() + + async def send(self, data): + await self.extension.on_data(self.ten_env, data) + + def ends(self): + return self.ten_env.named("tts_audio_end") + + def idle(self): + return ( + self.extension._processing_request_id is None + and len(self.extension.input_queue) == 0 + ) + + +def is_ttfb(kind, payload): + return kind == "metrics" and "ttfb" in payload["metrics"] + + +def is_usage(kind, payload): + return kind == "metrics" and "total_recv_audio_duration" in ( + payload["metrics"] + ) + + +@pytest.mark.asyncio +async def test_success_order_duration_and_interval(): + async with Harness( + [ + ("ttfb", 120), + ("audio", HUNDRED_MS), + ("advance", 125), + ("audio", HUNDRED_MS), + ("advance", 125), + ("usage", {"characters": 5}), + ] + ) as harness: + await harness.send(text_input("hello", True)) + await until(lambda: harness.ends() and harness.idle()) + env = harness.ten_env + start = env.index(lambda kind, _: kind == "tts_audio_start") + ttfb = env.index(is_ttfb) + first_frame = env.index(lambda kind, _: kind == "frame") + end = env.index(lambda kind, _: kind == "tts_audio_end") + usage = env.index(is_usage) + assert start < ttfb < first_frame < end < usage + assert env.named("frame") == [HUNDRED_MS, HUNDRED_MS] + assert env.events[ttfb][1]["metrics"]["ttfb"] == 120 + (payload,) = harness.ends() + assert payload["reason"] == TTSAudioEndReason.REQUEST_END + assert payload["request_total_audio_duration_ms"] == 200 + # Measured from tts_audio_start, not from the first audio frame. + assert payload["request_event_interval_ms"] == 250 + assert payload["metadata"]["router_usage"] == {"characters": 5} + assert not env.named("error") + + +@pytest.mark.asyncio +async def test_partial_audio_then_final_error_ends_once_before_error(): + async with Harness( + [ + ("ttfb", 80), + ("audio", HUNDRED_MS), + ("advance", 125), + ( + "error", + SpekoRouterError("provider_error", "boom", retryable=True), + ), + ] + ) as harness: + await harness.send(text_input("hello", True)) + await until(lambda: harness.ten_env.named("error") and harness.idle()) + env = harness.ten_env + (payload,) = harness.ends() + assert payload["reason"] == TTSAudioEndReason.ERROR + assert payload["request_total_audio_duration_ms"] == 100 + assert payload["request_event_interval_ms"] == 125 + (error,) = env.named("error") + assert error["code"] == int(ModuleErrorCode.NON_FATAL_ERROR.value) + assert error["vendor_info"] == { + "vendor": "speko", + "code": "provider_error", + "message": "boom", + } + end = env.index(lambda kind, _: kind == "tts_audio_end") + assert end < env.index(lambda kind, _: kind == "error") + + +@pytest.mark.asyncio +async def test_non_final_error_keeps_request_open_until_one_end(): + async with Harness( + [ + ("ttfb", 80), + ("audio", HUNDRED_MS), + ("advance", 125), + ( + "error", + SpekoRouterError("provider_error", "boom", retryable=True), + ), + ], + [("audio", HUNDRED_MS), ("advance", 125)], + ) as harness: + await harness.send(text_input("first part", False)) + await until(lambda: harness.ten_env.named("error")) + assert not harness.ends() + await harness.send(text_input("second part", True)) + await until(lambda: harness.ends() and harness.idle()) + env = harness.ten_env + (payload,) = harness.ends() + assert payload["reason"] == TTSAudioEndReason.REQUEST_END + assert payload["request_total_audio_duration_ms"] == 200 + assert payload["request_event_interval_ms"] == 250 + (error,) = env.named("error") + assert error["vendor_info"]["vendor"] == "speko" + assert env.index(lambda kind, _: kind == "error") < env.index( + lambda kind, _: kind == "tts_audio_end" + ) + assert len(env.named("tts_audio_start")) == 1 + + +@pytest.mark.asyncio +async def test_flush_mid_stream_ends_once_as_interrupted(): + async with Harness( + [ + ("ttfb", 60), + ("audio", HUNDRED_MS), + ("advance", 125), + ("block",), + ] + ) as harness: + await harness.send(text_input("hello", True)) + await until(harness.script.blocked.is_set) + await harness.send(flush()) + await until(lambda: harness.ten_env.named("tts_flush_end")) + # Give a late completion of the cancelled stream a chance to emit. + for _ in range(20): + await asyncio.sleep(0.001) + env = harness.ten_env + (payload,) = harness.ends() + assert payload["reason"] == TTSAudioEndReason.INTERRUPTED + assert payload["request_total_audio_duration_ms"] == 100 + assert payload["request_event_interval_ms"] == 125 + assert env.index(lambda kind, _: kind == "tts_audio_end") < env.index( + lambda kind, _: kind == "tts_flush_end" + ) + assert not env.named("error") + + +@pytest.mark.asyncio +async def test_no_audio_completion_ends_once_with_zero_duration(): + async with Harness([("advance", 125)]) as harness: + await harness.send(text_input("hello", True)) + await until(lambda: harness.ends() and harness.idle()) + env = harness.ten_env + (payload,) = harness.ends() + assert payload["reason"] == TTSAudioEndReason.REQUEST_END + assert payload["request_total_audio_duration_ms"] == 0 + assert payload["request_event_interval_ms"] == 0 + assert not env.named("frame") + assert len(env.named("tts_audio_start")) == 1 + + +@pytest.mark.asyncio +async def test_odd_sized_chunks_count_only_emitted_frames(): + async with Harness( + [ + ("ttfb", 50), + ("audio", HUNDRED_MS + 1), + ("probe",), + ("audio", HUNDRED_MS - 1), + ("probe",), + ] + ) as harness: + await harness.send(text_input("hello", True)) + await until(lambda: harness.ends() and harness.idle()) + # The odd byte waits in the base's leftover buffer for the next chunk. + assert harness.script.probes == [HUNDRED_MS, 2 * HUNDRED_MS] + assert harness.ten_env.named("frame") == [HUNDRED_MS, HUNDRED_MS] + (payload,) = harness.ends() + assert payload["request_total_audio_duration_ms"] == 200 diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py index f689ae3605..a72d2a2ec8 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py @@ -1,3 +1,4 @@ +import json from unittest.mock import AsyncMock, MagicMock import pytest @@ -114,16 +115,18 @@ async def stream(value): extension._ensure_client = AsyncMock(return_value=client) extension.metrics_add_output_characters = MagicMock() extension.metrics_add_input_characters = MagicMock() - for value in ("hello", " ", " world "): + for value in ("hello", " ", "\n\t", " world "): await extension.request_tts(text(value)) - assert sent == ["hello", " ", " world "] + # Whitespace-only chunks are not sent to the Router at all. + assert sent == ["hello", " world "] assert ( sum( call.args[0] for call in extension.metrics_add_output_characters.call_args_list ) - == 13 + == 12 ) + extension.send_tts_error.assert_not_awaited() extension.metrics_add_input_characters.assert_not_called() @@ -184,13 +187,13 @@ async def test_nonretryable_chunk_error_allows_new_text_without_replay(code): extension._stream_text = AsyncMock( side_effect=SpekoRouterError(code, "chunk failed", retryable=False) ) - await extension.request_tts(text(" ")) + await extension.request_tts(text("rejected")) extension.finish_request.assert_not_awaited() extension._stream_text.side_effect = None await extension.request_tts(text("valid next chunk", final=True)) assert [ call.args[0] for call in extension._stream_text.await_args_list - ] == [" ", "valid next chunk"] + ] == ["rejected", "valid next chunk"] extension.send_tts_error.assert_awaited_once() await extension.request_tts(text("new", "next", True)) extension._stream_text.assert_awaited_with("new", "next") @@ -205,7 +208,7 @@ async def test_flush_during_close_emits_only_interruption(): await extension._begin_request(text()) entered = asyncio.Event() - async def close(): + async def close(**_kwargs): entered.set() await asyncio.Future() @@ -283,7 +286,7 @@ async def test_old_close_does_not_clear_a_new_requests_client_or_usage(): extension.current_request_id = "old" entered, release = asyncio.Event(), asyncio.Event() - async def close(): + async def close(**_kwargs): entered.set() await release.wait() @@ -404,8 +407,6 @@ async def test_replaced_request_recorder_is_flushed_even_if_close_fails( @pytest.mark.asyncio async def test_connection_event_redacts_credentials_and_url(): - import json - extension = SpekoTTS2Extension("speko") extension.config = SpekoTTS2Config( api_key="test-secret-key-long", @@ -420,3 +421,157 @@ async def test_connection_event_redacts_credentials_and_url(): assert "key" in vendor and "api_key" not in vendor for secret in ("test-secret-key-long", "password", "private", "#secret"): assert secret not in raw + extension.config.to_str() + + +@pytest.mark.asyncio +async def test_final_chunk_error_reports_one_disconnect(): + extension = make_extension() + await extension._begin_request(text()) + extension._processing_request_id = "req" + client = MagicMock(usage={"characters": 3}) + client.close = AsyncMock() + + async def fail(*_): + extension.client = client + raise SpekoRouterError("provider_error", "boom", retryable=True) + + extension._stream_text = AsyncMock(side_effect=fail) + await extension.request_tts(text(final=True)) + extension.on_disconnected.assert_awaited_once() + assert extension.on_disconnected.await_args.kwargs["code"] != 0 + client.close.assert_awaited_once_with(drain=False) + extension.finish_request.assert_awaited_once() + assert extension.send_usage_metrics.await_args.kwargs["extra_metadata"][ + "router_usage" + ] == {"characters": 3} + + +@pytest.mark.asyncio +async def test_close_failure_after_stop_does_not_finish_as_success(): + extension = make_extension() + await extension._begin_request(text()) + extension._processing_request_id = "req" + client = MagicMock(usage={}) + + async def close(**_kwargs): + extension._stopped = True + raise OSError("closed during shutdown") + + client.close = AsyncMock(side_effect=close) + extension.client = client + await extension._finalize_request(TTSAudioEndReason.REQUEST_END) + extension.send_tts_audio_end.assert_not_awaited() + extension.send_tts_error.assert_not_awaited() + extension.finish_request.assert_awaited_once() + assert ( + extension.finish_request.await_args.kwargs["reason"] + == TTSAudioEndReason.INTERRUPTED + ) + + +class _ClosingSocket: + """A Router socket that fails during the close handshake.""" + + def __init__(self, failure): + self.failure = failure + self.sent = [] + self.closed = False + + async def send(self, value): + self.sent.append(value) + + async def recv(self): + if self.failure == "closed": + from websockets.exceptions import ConnectionClosedError + + raise ConnectionClosedError(None, None) + return json.dumps( + { + "type": "error", + "error": {"code": "provider_error", "message": "late"}, + } + ) + + async def close(self): + self.closed = True + + +def _ready_client(socket): + from speko_tts2_python.client import SpekoTTSClient + + client = SpekoTTSClient( + api_key="key", + base_url="https://router.speko.dev", + configure={}, + ready_timeout_sec=1, + receive_timeout_sec=1, + ) + client._ws = socket + client._ready = True + return client + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["closed", "error"]) +async def test_stop_never_raises_when_router_close_fails(failure): + extension = SpekoTTS2Extension("speko") + extension.config = SpekoTTS2Config(api_key="key") + extension.ten_env = MagicMock( + send_data=AsyncMock(), send_audio_frame=AsyncMock() + ) + socket = _ClosingSocket(failure) + extension.client = _ready_client(socket) + extension.current_request_id = "req" + await extension.on_stop(extension.ten_env) + assert extension.client is None + assert socket.closed + + +@pytest.mark.asyncio +async def test_stop_logs_instead_of_raising_when_close_raises(): + extension = SpekoTTS2Extension("speko") + extension.config = SpekoTTS2Config(api_key="key") + extension.ten_env = MagicMock(send_data=AsyncMock()) + extension.client = MagicMock( + close=AsyncMock(side_effect=SpekoRouterError("relay_error", "x")) + ) + extension.current_request_id = "req" + extension._recorders["req"] = MagicMock( + flush=AsyncMock(side_effect=OSError("disk")) + ) + await extension.on_stop(extension.ten_env) + assert extension.ten_env.log_warn.call_count == 2 + assert not extension._recorders + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["closed", "error"]) +async def test_drain_treats_closed_socket_as_closed_and_surfaces_errors( + failure, +): + socket = _ClosingSocket(failure) + client = _ready_client(socket) + if failure == "closed": + await client.close(drain=True) + else: + with pytest.raises(SpekoRouterError): + await client.close(drain=True) + assert socket.closed + assert not client.is_ready + + +@pytest.mark.asyncio +async def test_drain_is_bounded_while_audio_keeps_arriving(): + import asyncio + from unittest.mock import patch + + class StreamingSocket(_ClosingSocket): + async def recv(self): + await asyncio.sleep(0.001) + return b"\x00\x00" + + socket = StreamingSocket("audio") + client = _ready_client(socket) + with patch("speko_tts2_python.client.DRAIN_TIMEOUT_SEC", 0.05): + await asyncio.wait_for(client.close(drain=True), 1) + assert socket.closed From 0d5e98f70474ee935ee76835e74e865635c3d90d Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 00:10:33 +0500 Subject: [PATCH 09/17] test(tts_guarder): add speko ttfb reference hook SpekoObserver times the first non-whitespace input.append frame to the first nonempty binary audio frame per connection, excluding control frames and Router JSON events. The guarder maps base_url to the Router TTS socket itself. Co-Authored-By: Claude Opus 5.5 --- .../tts_guarder/tests/TTFB_TESTING.md | 24 +++++++ .../tts_guarder/tests/test_ttfb_endpoints.py | 17 ++++- .../tts_guarder/tests/test_ttfb_hooks.py | 68 ++++++++++++++++++- .../tts_guarder/tests/ttfb_hooks/speko.py | 24 +++++++ .../tts_guarder/tests/ttfb_reference.py | 21 +++++- 5 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 ai_agents/agents/integration_tests/tts_guarder/tests/ttfb_hooks/speko.py diff --git a/ai_agents/agents/integration_tests/tts_guarder/tests/TTFB_TESTING.md b/ai_agents/agents/integration_tests/tts_guarder/tests/TTFB_TESTING.md index 271fbbc229..85d516e282 100644 --- a/ai_agents/agents/integration_tests/tts_guarder/tests/TTFB_TESTING.md +++ b/ai_agents/agents/integration_tests/tts_guarder/tests/TTFB_TESTING.md @@ -181,3 +181,27 @@ After these changes, all seven live sequential-request TTFB configurations also passed with the independent reference enabled and the existing 1 ms tolerance. The endpoint checks and single-extension installation checks are separate from that live result: the live verification app supplies all selected packages. + +## Speko Router hook + +As of: 2026-11-12 + +`speko_tts2_python` registers `ttfb_hooks/speko.py` (`SpekoObserver`) on the +extension's `client` module. The guarder maps the configured `base_url` to the +Router's TTS socket itself (`https`→`wss`, `http`→`ws`, base path kept, then +`/v1/tts/stream`); it does not import the client's URL helper. Text is the first +`input.append` JSON frame with non-whitespace text; audio is the first nonempty +binary frame on that connection. `session.configure`, `input.commit`, +`input.cancel`, `session.close` and every Router JSON event are excluded. The +Router runs one utterance per session, so audio follows the connection's active +text request, and a previous connection's audio cannot complete a new request. + +Offline coverage joins the parametrized wire, deferred-decode and local +WebSocket transport tests, plus Speko-specific control-frame and +connection-isolation cases. Endpoint cases cover `https://r.test/edge/` → +`wss://r.test/edge/v1/tts/stream` and `http://127.0.0.1:8080` → +`ws://127.0.0.1:8080/v1/tts/stream`. Speko normalization requires `api_key`, +so the default-endpoint test supplies a placeholder. The extension reports TTFB +from just before the `input.append` send to the first audio frame, rounded to +the nearest millisecond, to stay within the 1 ms reference tolerance. The live +sequential-request run for Speko has not been recorded here yet. diff --git a/ai_agents/agents/integration_tests/tts_guarder/tests/test_ttfb_endpoints.py b/ai_agents/agents/integration_tests/tts_guarder/tests/test_ttfb_endpoints.py index de9b1d3c0d..31ebd6fe22 100644 --- a/ai_agents/agents/integration_tests/tts_guarder/tests/test_ttfb_endpoints.py +++ b/ai_agents/agents/integration_tests/tts_guarder/tests/test_ttfb_endpoints.py @@ -41,8 +41,21 @@ {"params": {"url": "${env:TTFB_ENDPOINT|}"}}, "wss://env.test/ws", ), + ( + "speko_tts2_python", + {"params": {"api_key": "k", "base_url": "https://r.test/edge/"}}, + "wss://r.test/edge/v1/tts/stream", + ), + ( + "speko_tts2_python", + {"params": {"api_key": "k", "base_url": "http://127.0.0.1:8080"}}, + "ws://127.0.0.1:8080/v1/tts/stream", + ), ] +# Normalization of these extensions also validates a required credential. +MINIMAL_CONFIGS = {"speko_tts2_python": {"params": {"api_key": "k"}}} + @pytest.mark.parametrize("vendor,config,expected", CASES) def test_selected_extension_endpoint_precedence( @@ -58,7 +71,9 @@ def test_selected_extension_endpoint_precedence( def test_selected_extension_default_endpoint(extension_name): if extension_name not in CONFIGS: pytest.skip("No TTFB endpoint registered for this extension") - _, normalized, url = effective_config(extension_name, {}) + _, normalized, url = effective_config( + extension_name, MINIMAL_CONFIGS.get(extension_name, {}) + ) if extension_name == "elevenlabs_multiple_context_tts_python": url = url or normalized.params.get("base_url") or HOOKS[extension_name][3] assert url.startswith("wss://") diff --git a/ai_agents/agents/integration_tests/tts_guarder/tests/test_ttfb_hooks.py b/ai_agents/agents/integration_tests/tts_guarder/tests/test_ttfb_hooks.py index d3d59266d5..a6b6be1005 100644 --- a/ai_agents/agents/integration_tests/tts_guarder/tests/test_ttfb_hooks.py +++ b/ai_agents/agents/integration_tests/tts_guarder/tests/test_ttfb_hooks.py @@ -1,4 +1,4 @@ -"""Deterministic wire fixtures for all seven first-batch TTFB modes.""" +"""Deterministic wire fixtures for the first-batch TTFB modes and Speko.""" import gzip import json @@ -11,6 +11,7 @@ from .ttfb_hooks.bytedance_duplex import ByteDanceDuplexObserver from .ttfb_hooks.minimax import MiniMaxObserver from .ttfb_hooks.soul import SoulObserver +from .ttfb_hooks.speko import SpekoObserver from .test_soul_ttfb_reference import gateway_frame @@ -41,6 +42,10 @@ def wire(mode, context, audio=False, empty=False): json.dumps({"request": {"reqid": context, "text": text}}).encode() ) return b"\x11\x10\x11\x00" + len(body).to_bytes(4, "big") + body + if mode == "speko": + if audio: + return pcm + return json.dumps({"type": "input.append", "text": text}) if mode == "bytedance-duplex": return duplex( 352 if audio else 200, @@ -82,6 +87,7 @@ def wire(mode, context, audio=False, empty=False): ("minimax", MiniMaxObserver), ("minimax-duplex", MiniMaxObserver), ("soul", SoulObserver), + ("speko", SpekoObserver), ], ) def test_hook_times_only_first_text_and_nonempty_audio( @@ -143,6 +149,7 @@ def test_context_does_not_cross_requests(monkeypatch, mode, observer_class): ("minimax", "minimax_tts_websocket", "minimax_tts"), ("minimax-duplex", "minimax_tts_websocket_duplex", "minimax_tts"), ("soul", "soul_tts_python", "soul_tts"), + ("speko", "speko_tts2_python", "client"), ], ) def test_hook_installs_on_transport_and_restores( @@ -179,6 +186,10 @@ async def provider(socket): async with websockets.serve(provider, "127.0.0.1", 0) as server: port = server.sockets[0].getsockname()[1] url = f"ws://127.0.0.1:{port}/test" + # Speko configures an origin; the client appends the stream path. + connect_url = ( + f"{url}/v1/tts/stream" if extension == "speko_tts2_python" else url + ) model = "eleven_v4" if mode == "ttd" else "eleven_flash_v2_5" # The installer only needs a dependency binding and config contract. # No vendor package/SDK is required by these transport utility tests. @@ -193,6 +204,7 @@ def __init__(self, **values): self.url = values["url"] self.uri = self.url self.api_url = self.url + self.base_url = self.url self.model_id = values["params"]["model_id"] self.params = values["params"] @@ -212,7 +224,7 @@ def update_params(self): ) as reference: assert reference is not None reference.begin_request(request) - async with module.websockets.connect(url) as socket: + async with module.websockets.connect(connect_url) as socket: await socket.send(outgoing) assert await socket.recv() == wire( mode, request, audio=True, empty=True @@ -235,6 +247,7 @@ def update_params(self): ("minimax", MiniMaxObserver), ("minimax-duplex", MiniMaxObserver), ("soul", SoulObserver), + ("speko", SpekoObserver), ], ) def test_hook_defers_decode_and_retains_first_audio_arrival( @@ -293,3 +306,54 @@ def test_reading_previous_request_does_not_return_pending_request(monkeypatch): observer.observe_send(wire("tts", "second")) assert reference.elapsed_ms("first") == 20 assert reference.elapsed_ms("second") is None + + +def test_speko_hook_ignores_control_frames_and_router_events(monkeypatch): + now = [0] + monkeypatch.setattr("tests.ttfb_hooks.base.time.monotonic_ns", lambda: now[0]) + reference = TTFBReference() + observer = SpekoObserver(reference) + reference.begin_request("request") + for frame in ( + {"type": "session.configure", "audio": {"encoding": "pcm_s16le"}}, + {"type": "input.append", "text": " \n\t"}, + {"type": "input.commit"}, + {"type": "input.cancel"}, + {"type": "session.close"}, + ): + observer.observe_send(json.dumps(frame)) + now[0] = 10_000_000 + observer.observe_send(wire("speko", "request")) + observer.observe_send(json.dumps({"type": "input.commit"})) + for event in ( + {"type": "session.ready", "request_id": "router"}, + {"type": "utterance.started", "sequence": 1}, + {"type": "usage.updated", "usage": {"characters": 6}}, + ): + now[0] += 5_000_000 + observer.observe_receive(json.dumps(event)) + now[0] = 40_000_000 + observer.observe_receive(b"") + assert reference.elapsed_ms("request") is None + now[0] = 45_000_000 + observer.observe_receive(wire("speko", "request", audio=True)) + observer.observe_receive(json.dumps({"type": "utterance.done", "sequence": 1})) + assert reference.elapsed_ms("request") == 35 + + +def test_speko_old_connection_audio_cannot_complete_new_request(monkeypatch): + now = [0] + monkeypatch.setattr("tests.ttfb_hooks.base.time.monotonic_ns", lambda: now[0]) + reference = TTFBReference() + old_connection = SpekoObserver(reference) + reference.begin_request("old") + old_connection.observe_send(wire("speko", "old")) + reference.begin_request("new") + connection = SpekoObserver(reference) + connection.observe_send(wire("speko", "new")) + now[0] = 10_000_000 + old_connection.observe_receive(wire("speko", "old", audio=True)) + assert reference.elapsed_ms("new") is None + now[0] = 20_000_000 + connection.observe_receive(wire("speko", "new", audio=True)) + assert reference.elapsed_ms("new") == 20 diff --git a/ai_agents/agents/integration_tests/tts_guarder/tests/ttfb_hooks/speko.py b/ai_agents/agents/integration_tests/tts_guarder/tests/ttfb_hooks/speko.py new file mode 100644 index 0000000000..626a0c872c --- /dev/null +++ b/ai_agents/agents/integration_tests/tts_guarder/tests/ttfb_hooks/speko.py @@ -0,0 +1,24 @@ +"""Speko Router input.append text and nonempty binary PCM audio frames.""" + +import json + +from .base import ConnectionObserver + + +class SpekoObserver(ConnectionObserver): + def text_context(self, raw, request_id): + # configure/commit/cancel/close are control frames, never text. + if not isinstance(raw, str): + return None + message = json.loads(raw) + text = message.get("text") + if message.get("type") == "input.append" and isinstance(text, str) and text.strip(): + # One utterance is in flight per Router session. + return request_id + return None + + def audio_context(self, raw): + # Router events are JSON text frames; only binary frames carry PCM. + if isinstance(raw, bytes) and raw: + return self.active_context + return None diff --git a/ai_agents/agents/integration_tests/tts_guarder/tests/ttfb_reference.py b/ai_agents/agents/integration_tests/tts_guarder/tests/ttfb_reference.py index 15f03337e9..926bf1b719 100644 --- a/ai_agents/agents/integration_tests/tts_guarder/tests/ttfb_reference.py +++ b/ai_agents/agents/integration_tests/tts_guarder/tests/ttfb_reference.py @@ -4,6 +4,7 @@ from contextlib import contextmanager from importlib import import_module from threading import RLock +from urllib.parse import urlsplit, urlunsplit from .timing_hooks.transport import ObservedWebSockets @@ -93,6 +94,12 @@ def elapsed_ms(self, request_id): "soul_tts", "wss://qa-soul-llm.soulapp.cn/llm", ), + "speko_tts2_python": ( + "speko", + "SpekoObserver", + "client", + "wss://router.speko.dev/v1/tts/stream", + ), } @@ -103,9 +110,18 @@ def elapsed_ms(self, request_id): "minimax_tts_websocket": ("MinimaxTTSWebsocketConfig", "url"), "minimax_tts_websocket_duplex": ("MinimaxTTSWebsocketDuplexConfig", "url"), "soul_tts_python": ("SoulTTSWebsocketConfig", "url"), + "speko_tts2_python": ("SpekoTTS2Config", "base_url"), } +def speko_stream_url(base_url): + """The Router's TTS WebSocket for a configured HTTP(S) or WS(S) origin.""" + parsed = urlsplit(base_url) + scheme = {"https": "wss", "http": "ws"}.get(parsed.scheme, parsed.scheme) + path = parsed.path.rstrip("/") + "/v1/tts/stream" + return urlunsplit((scheme, parsed.netloc, path, "", "")) + + def resolve_environment(value): if isinstance(value, dict): return {key: resolve_environment(item) for key, item in value.items()} @@ -129,7 +145,10 @@ def effective_config(extension_name, config): if extension_name == "elevenlabs_multiple_context_tts_python": normalized.apply_defaults() normalized.update_params() - return module, normalized, getattr(normalized, field) + url = getattr(normalized, field) + if extension_name == "speko_tts2_python": + url = speko_stream_url(url) + return module, normalized, url @contextmanager From 6ceb7a530cb3fef914c22ef5d8034fbeef1385ec Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 01:10:25 +0500 Subject: [PATCH 10/17] fix(speko_tts2_python): stamp first audio inside the receive task The live guarder's provider-boundary reference saw TTFB 192.5 ms where the extension reported 195 ms (1 ms tolerance). The arrival time was read after asyncio.wait_for's task hop back to the caller, which can take milliseconds under load. Take it in the receive coroutine, right after recv returns. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_tts2_python/client.py | 14 ++++++-- .../speko_tts2_python/tests/test_client.py | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py index 45f9cf5a01..0d7b799108 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py @@ -168,8 +168,9 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: while not self._cancelled: try: - raw = await asyncio.wait_for( - websocket.recv(), timeout=self.receive_timeout_sec + raw, received_at = await asyncio.wait_for( + self._timed_recv(websocket), + timeout=self.receive_timeout_sec, ) except ConnectionClosed: if self._cancelled: @@ -195,7 +196,7 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: first_audio = False yield SpekoTTSEvent( SpekoTTSEventType.TTFB, - round((time.monotonic() - sent_at) * 1000), + round((received_at - sent_at) * 1000), ) if self._cancelled: return @@ -230,6 +231,13 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: retryable=True, ) + @staticmethod + async def _timed_recv(websocket: ClientConnection) -> tuple[Any, float]: + raw = await websocket.recv() + # Stamp arrival in wait_for's own task, before the hop back to the + # caller: under load that hop alone can exceed a millisecond. + return raw, time.monotonic() + async def cancel(self) -> None: self._cancelled = True if self._ws is not None and self._ready: diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py index d42ad33835..20c2596e87 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py @@ -300,3 +300,35 @@ async def test_cancel_discards_a_receive_completing_during_close(): websocket.messages.put_nowait(b"late audio") with pytest.raises(StopAsyncIteration): await receiving + + +@pytest.mark.asyncio +async def test_ttfb_uses_arrival_time_taken_in_the_receive_task(): + websocket = FakeWebSocket( + json.dumps({"type": "session.ready", "route": {}}), + json.dumps({"type": "utterance.started", "sequence": 1}), + b"pcm", + json.dumps({"type": "utterance.done", "sequence": 1}), + ) + # Before append; arrival of utterance.started; arrival of audio. Any + # later reading (after the task hop back) would see the last value. + readings = iter([10.0, 10.1, 10.25]) + + class Clock: + """Replaces only the client's clock, not the event loop's.""" + + @staticmethod + def monotonic(): + return next(readings, 99.0) + + client = make_client() + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + await client.connect() + with patch("speko_tts2_python.client.time", Clock): + events = [event async for event in client.stream_text("hello")] + ttfb = [event for event in events if event.type == SpekoTTSEventType.TTFB] + assert [event.value for event in ttfb] == [250] + await client.close(drain=False) From 89c4bc849a061f347251d0040ad0f3fdaca6332f Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 03:21:34 +0500 Subject: [PATCH 11/17] fix(speko_asr_python): report a fatal setup failure once A caller queued on _connection_lock behind a handshake that failed fatally passed the unlocked latch check, so the base start_connection wrapper emitted a second connecting -> disconnected pair for the same root cause. Re-check the latch under the lock. Audio arriving before on_start also scheduled a reconnect that raced the first handshake and spent retry budget. Schedule audio-driven reconnects only after a connection has been requested. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_asr_python/extension.py | 25 +++++++++++---- .../speko_asr_python/tests/test_extension.py | 11 +++++++ .../speko_asr_python/tests/test_reporting.py | 32 +++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py index 40275b537b..c7c66cb9c8 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -130,16 +130,26 @@ async def on_init(self, ten_env: AsyncTenEnv) -> None: ) ) + @override async def _ensure_connection(self) -> None: - # Guard before the base's start_connection wrapper emits connecting. - if self.stopped or self._init_failed or self._permanent_error: + # The base's start_connection wrapper emits connecting, so check the + # latch before it and again under the lock: a caller queued behind a + # handshake that just failed fatally must not report that failure twice. + if self._connection_disabled(): return - await super()._ensure_connection() + self._connection_requested = True + async with self._connection_lock: + if self._connection_disabled() or self._safe_is_connected(): + return + await self.start_connection() + + def _connection_disabled(self) -> bool: + return bool(self.stopped or self._init_failed or self._permanent_error) @override async def start_connection(self) -> None: async with self._swap_lock: - if self.stopped or self._init_failed or self._permanent_error: + if self._connection_disabled(): await self.on_disconnected( code=ModuleErrorCode.FATAL_ERROR.value, message="Speko ASR connection is disabled", @@ -313,10 +323,13 @@ async def _enqueue_audio_frame( # the base queue must not duplicate bytes in this raw-input dump. if self.audio_dumper is not None: await self.audio_dumper.push_bytes(bytes(frame.get_buf())) + # Before the first requested connection (on_start with auto_connect, or + # trigger_connect), audio only buffers: a retry scheduled now would race + # that handshake and spend retry budget on it. if ( not self.is_connected() and self.connection_status != ModuleConnectionStatus.CONNECTING - and (self.auto_connect or self._connection_requested) + and self._connection_requested ): self._schedule_reconnect() self._audio_queue_drained.clear() @@ -378,7 +391,7 @@ async def _reconnect(self) -> None: delay = self.RECONNECT_DELAYS[self._reconnect_attempts] async with self._swap_lock: await self._close_retired_clients() - if self.stopped or self._init_failed or self._permanent_error: + if self._connection_disabled(): return if self.is_connected(): return diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py index a51df95626..d30cc5ba07 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py @@ -190,6 +190,7 @@ async def test_audio_ingress_does_not_wait_for_reconnection_and_stop_joins_it(): extension, _ = make_extension() extension.client = None + extension._connection_requested = True # dropped after on_start entered = asyncio.Event() async def connect(): @@ -258,6 +259,16 @@ async def test_audio_during_initial_connection_does_not_schedule_retry(): assert extension.audio_frames_queue.qsize() == 1 +@pytest.mark.asyncio +async def test_audio_before_start_waits_for_first_connection_without_retry(): + extension, _ = make_extension() + extension.client = None + await extension.on_audio_frame(extension.ten_env, frame()) + assert extension._reconnect_task is None + assert extension._reconnect_attempts == 0 + assert extension.audio_frames_queue.qsize() == 1 + + @pytest.mark.asyncio async def test_reconnect_waits_for_failed_session_cleanup(): import asyncio diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py index 63a878b98e..ec3a6c2569 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py @@ -298,6 +298,38 @@ async def test_non_retryable_setup_failure_is_one_fatal_error_without_retry( assert not ext._retired_clients +@pytest.mark.asyncio +async def test_caller_queued_behind_fatal_handshake_emits_no_transition(): + ext, events = extension_and_events() + ext.client = None + entered, release = asyncio.Event(), asyncio.Event() + rejected = _upgrade_rejected(401) + + async def connect(*args, **kwargs): + entered.set() + await release.wait() + return await rejected(*args, **kwargs) + + with patch("speko_asr_python.client.websockets.connect", connect): + first = asyncio.create_task(ext._ensure_connection()) + await entered.wait() + # Passes the unlocked latch check, then queues on _connection_lock. + queued = asyncio.create_task(ext._ensure_connection()) + await asyncio.sleep(0) + release.set() + await asyncio.gather(first, queued) + statuses = [ + (status["current"], status["code"]) + for status in payloads(events, "connection_status_changed") + ] + assert statuses == [ + ("connecting", 0), + ("disconnected", int(ModuleErrorCode.FATAL_ERROR.value)), + ] + assert len(payloads(events, "error")) == 1 + assert rejected.await_count == 1 + + @pytest.mark.asyncio async def test_retryable_setup_failure_reports_non_fatal_and_reconnects(): ext, events = extension_and_events() From 984db543608555e34d316764e84fe807a6267f8f Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 05:56:22 +0500 Subject: [PATCH 12/17] fix(speko_asr_python): report TTLW when finalize has no final result The base class emits TTLW only from a final asr_result that arrives after asr_finalize. When the provider had already finalized the turn (the client skips the commit) or the Router acknowledged the commit with an empty final, the extension completed the finalize without a result, so no TTLW was ever sent and the guarder's test_metrics timed out on auto-routed sessions. Empty finals stay suppressed; instead, an acknowledged commit that no final consumed reports TTLW as the latency to that acknowledgement. A commit timeout or disconnect still reports none. Log the Router request_id, route and attempt_id on session.ready so support reports can cite them. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_asr_python/README.md | 7 ++- .../extension/speko_asr_python/extension.py | 21 +++++++ .../speko_asr_python/tests/test_extension.py | 29 +++++++++ .../speko_asr_python/tests/test_reporting.py | 60 +++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md index 2ced91bbb2..bdc55205a5 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -34,7 +34,12 @@ An empty `transcript.final` acknowledges the commit. If no final arrives within `finalize_timeout_sec` (the provider may already have finalized the turn), the extension logs a warning, keeps the session, and still completes the finalize; only a real disconnect reports an error and reconnects. -Empty vendor transcripts are not emitted as user turns. +Empty vendor transcripts are not emitted as user turns. TTLW comes from the +first non-empty final after `asr_finalize`; when the commit is acknowledged +without one (already-finalized input or an empty final), TTLW is the latency to +that acknowledgement. A commit timeout or disconnect reports no TTLW. +Each `session.ready` logs the Router `request_id` and the route, including its +`attempt_id`, for support reports. ```json { diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py index c7c66cb9c8..f225863d23 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -480,6 +480,7 @@ async def on_data(self, ten_env: AsyncTenEnv, data: Data) -> None: async def finalize(self, session_id: str | None) -> None: del session_id error = None + acknowledged = False epoch = self._connection_epoch try: async with self._io_lock: @@ -490,6 +491,7 @@ async def finalize(self, session_id: str | None) -> None: self._commit_task = asyncio.create_task(client.commit()) try: await self._commit_task + acknowledged = True except SpekoCommitTimeout as timeout: # The provider may have finalized this turn on its own, so # the Router has no final left to send. The session is @@ -517,6 +519,16 @@ async def finalize(self, session_id: str | None) -> None: if error is not None: await self._reset_connection(error, epoch) if not self.stopped: + if acknowledged and self.last_finalize_time is not None: + # No non-empty final consumed the finalize: the provider + # had already finalized the turn, or the Router acked + # with an empty final. TTLW is the latency to that ack. + elapsed = ( + asyncio.get_running_loop().time() + - self.last_finalize_time + ) + await self._send_metrics_ttlw(int(elapsed * 1000)) + self.last_finalize_time = None # send_asr_finalize_end reads mutable base metadata. Snapshot # only while reporting, after commit releases its I/O lock. async with self._metadata_lock: @@ -575,6 +587,15 @@ async def _on_router_event(self, event: dict[str, Any]) -> None: if event_type == "session.ready": self._route = dict(event.get("route", {})) self._reconnect_attempts = 0 + self.ten_env.log_info( + "Speko ASR session.ready: " + f"request_id={event.get('request_id', '')} " + f"route={self._route.get('provider', '')}/" + f"{self._route.get('model', '')} " + f"region={self._route.get('region', '')} " + f"attempt_id={self._route.get('attempt_id', '')}", + category=LOG_CATEGORY_KEY_POINT, + ) # Requeue in order through the base consumer, which reads metadata # per frame. No new ingress is needed to release buffered audio. if not self.buffered_frames.empty(): diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py index d30cc5ba07..04cd55ba84 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py @@ -249,6 +249,35 @@ async def test_connection_ready_requeues_early_audio_before_live_frames(): assert extension.audio_frames_queue.get_nowait() is live +@pytest.mark.asyncio +async def test_session_ready_logs_request_id_and_route_without_key(): + extension, _ = make_extension() + extension.on_connected = AsyncMock() + extension.send_connect_delay_metrics = AsyncMock() + await extension._on_router_event( + { + "type": "session.ready", + "request_id": "req_1", + "route": { + "provider": "meta", + "model": "muse", + "region": "eu-central-1", + "attempt_id": "ratt_1", + }, + } + ) + messages = [ + call.args[0] for call in extension.ten_env.log_info.call_args_list + ] + assert any( + "request_id=req_1" in message + and "route=meta/muse" in message + and "attempt_id=ratt_1" in message + for message in messages + ) + assert not any("test-key" in message for message in messages) + + @pytest.mark.asyncio async def test_audio_during_initial_connection_does_not_schedule_retry(): extension, _ = make_extension() diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py index ec3a6c2569..308fb0756a 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py @@ -225,6 +225,66 @@ async def commit(): await ext.on_stop(ext.ten_env) +def ttlw_metrics(events): + return [ + payload + for payload in payloads(events, "metrics") + if "ttlw" in payload["metrics"] + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "final_text", + [None, "", "done"], + ids=["short_circuited_commit", "empty_final_ack", "final_result"], +) +async def test_acknowledged_finalize_reports_ttlw_once(final_text): + ext, events = extension_and_events() + await ext._handle_audio_frame(ext.ten_env, audio()) + + async def commit(): + if final_text is not None: + await ext._on_router_event( + {"type": "transcript.final", "text": final_text} + ) + + ext.client.commit.side_effect = commit + await ext.on_data(ext.ten_env, finalize_data("one", "turn-one")) + ttlw = ttlw_metrics(events) + assert len(ttlw) == 1 + assert ttlw[0]["metrics"]["ttlw"] >= 0 + assert ttlw[0]["metadata"]["session_id"] == "audio-session" + kinds = [ + "ttlw" if kind == "metrics" and "ttlw" in payload["metrics"] else kind + for kind, payload in events + ] + assert kinds.index("ttlw") < kinds.index("asr_finalize_end") + assert len(payloads(events, "asr_result")) == (1 if final_text else 0) + assert ext.last_finalize_time is None + await ext.on_stop(ext.ten_env) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + [ + SpekoCommitTimeout("request_timeout", "timeout", retryable=True), + SpekoRouterError("relay_error", "closed", retryable=True), + ], + ids=["commit_timeout", "disconnect"], +) +async def test_unacknowledged_finalize_reports_no_ttlw(failure): + ext, events = extension_and_events() + await ext._handle_audio_frame(ext.ten_env, audio()) + ext.client.commit.side_effect = failure + await ext.on_data(ext.ten_env, finalize_data("one", "turn-one")) + await ext.stop_connection() + assert not ttlw_metrics(events) + assert len(payloads(events, "asr_finalize_end")) == 1 + assert ext.last_finalize_time is None + + def _upgrade_rejected(status): from websockets.exceptions import InvalidStatus From 6ad804ca2749cc108348811c1324ee16aa2458f4 Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 06:41:15 +0500 Subject: [PATCH 13/17] fix(speko_asr): pair commit acks FIFO and keep buffered audio per session Address the PR #2401 review of speko_asr_python: - The Router answers every input.commit with exactly one transcript.final, in order. Commit whenever audio was sent since the last commit and pair finals with outstanding commits FIFO. A timed-out commit stays outstanding, so its late final is reported under its own turn and does not complete the next finalize. Finals with no commit outstanding are unsolicited provider results. - Audio held while disconnected belongs to one session. A frame from a new session, or a finalize while disconnected, drops the older session's held frames and records them as dropped audio instead of replaying them into the next stream. - A normal close (1000/1001, session.closed) reports disconnected and reconnects without an asr_error. - Reconnect attempts are unlimited by default with backoff capped at 4 s. max_reconnect_attempts sets a ceiling that latches retry_exhausted. - The acknowledged-finalize TTLW and finalize_end are reported under the finalize's own metadata and session_id. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_asr_python/README.md | 39 ++- .../extension/speko_asr_python/client.py | 111 +++++--- .../extension/speko_asr_python/config.py | 4 + .../extension/speko_asr_python/extension.py | 140 +++++++--- .../extension/speko_asr_python/manifest.json | 3 +- .../extension/speko_asr_python/property.json | 3 +- .../speko_asr_python/tests/test_client.py | 57 ++-- .../speko_asr_python/tests/test_extension.py | 2 +- .../speko_asr_python/tests/test_reporting.py | 258 +++++++++++++++--- 9 files changed, 471 insertions(+), 146 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md index bdc55205a5..51c8aeee5d 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -18,6 +18,7 @@ your graph. | `params.routing` | auto, balanced | Speko routing selection | | `params.options` | `{}` | Diarization, keywords, noise reduction, or namespaced provider options | | `params.buffer_duration_ms` | `5000` | Audio retained while disconnected, `0`–`30000`; `0` discards | +| `params.max_reconnect_attempts` | `0` | Reconnect attempts before a fatal `retry_exhausted`; `0` retries until stop | The disconnected buffer is held in memory and is also capped at 10 MiB, which binds before 30 seconds at high sample rates or channel counts. When a frame @@ -26,14 +27,22 @@ the whole buffer is dropped itself). Dropped frames are never sent; they are recorded as dropped audio in the TEN audio timeline, so later transcript timestamps still line up with the caller's audio. +The buffer holds one session at a time. A frame whose metadata `session_id` +differs from the held frames' drops them, and a finalize while disconnected +drops the finished turn's held frames, both recorded as dropped audio: the +replay after reconnecting never merges one session's speech into another's. + The transport is raw signed 16-bit little-endian PCM. A TEN `asr_finalize` -message sends Speko `input.commit`. Each finalize is serialized and acknowledged -with its own `finalize_id` and metadata, including disconnected and timeout paths. -Silent or already-finalized input completes without waiting for another transcript. -An empty `transcript.final` acknowledges the commit. If no final arrives within -`finalize_timeout_sec` (the provider may already have finalized the turn), the -extension logs a warning, keeps the session, and still completes the finalize; -only a real disconnect reports an error and reconnects. +message sends Speko `input.commit` when any audio was sent since the last +commit. Each finalize is serialized and acknowledged with its own `finalize_id` +and metadata, including disconnected and timeout paths. The Router answers +every commit with exactly one `transcript.final`, in order (empty when the +provider had nothing left to finalize), so finals pair with commits first in, +first out; a final with no commit outstanding is the provider's own endpoint. +If no final arrives within `finalize_timeout_sec`, the extension logs a +warning, keeps the session, and still completes the finalize. That commit +stays outstanding: its late final answers it, is reported with the timed-out +turn's metadata, and never completes the next finalize. Empty vendor transcripts are not emitted as user turns. TTLW comes from the first non-empty final after `asr_finalize`; when the commit is acknowledged without one (already-finalized input or an empty final), TTLW is the latency to @@ -86,12 +95,16 @@ and `--config_dir /absolute/path/to/configs`. Keep invalid-key configs invalid. Client/lifecycle unit tests use mocked transport and do not require credentials. Transient disconnects have one background recovery loop with delays of 0.5, 1, -2, 4, and 4 seconds. A successful handshake resets this retry budget. Exhaustion, -invalid configuration, authentication/credit failures, unavailable explicit -routes, and any handshake rejection the Router marks non-retryable (for example -HTTP 400 on upgrade, or an `error` event before `session.ready` with -`retryable: false`) report one fatal error and stop further attempts until the -cause is corrected and the extension is restarted. Failed sends are not replayed; disconnected input uses the bounded +2, and then 4 seconds per attempt, retrying until stop unless +`max_reconnect_attempts` sets a ceiling. A successful handshake resets the +attempt count. A normal close (WebSocket 1000/1001 or `session.closed`) reports +`disconnected` and recovers without an `error`; an abnormal close or a Router +error reports one. An exhausted ceiling, invalid configuration, +authentication/credit failures, unavailable explicit routes, and any handshake +rejection the Router marks non-retryable (for example HTTP 400 on upgrade, or +an `error` event before `session.ready` with `retryable: false`) report one +fatal error and stop further attempts until the cause is corrected and the +extension is restarted. Failed sends are not replayed; disconnected input uses the bounded buffer. Stop cancels pending recovery and finalization. Finalization waits for an in-progress handshake and previously accepted audio diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py index 28d84f27cb..cc8c44ddca 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py @@ -1,6 +1,7 @@ import asyncio import json import uuid +from collections import deque from collections.abc import Awaitable, Callable from typing import Any from urllib.parse import urlparse, urlunparse @@ -46,6 +47,26 @@ class SpekoCommitTimeout(SpekoRouterError): """input.commit got no transcript.final; the session itself is intact.""" +# Set on a transcript.final that answers a commit whose caller stopped waiting +# (timeout); holds the context that caller passed to commit(). +LATE_COMMIT_CONTEXT = "speko_late_commit_context" + + +class _PendingCommit: + """An input.commit sent and not yet answered by a transcript.final.""" + + def __init__(self, context: Any) -> None: + self.answered: asyncio.Future[None] = ( + asyncio.get_running_loop().create_future() + ) + # An abandoned commit's failure has nobody awaiting it. + self.answered.add_done_callback( + lambda future: future.cancelled() or future.exception() + ) + self.context = context + self.abandoned = False + + def websocket_url(base_url: str, path: str) -> str: parsed = urlparse(base_url) scheme = {"https": "wss", "http": "ws"}.get(parsed.scheme, parsed.scheme) @@ -78,9 +99,11 @@ def __init__( self.usage: dict[str, Any] = {} self._ws: ClientConnection | None = None self._listener_task: asyncio.Task[None] | None = None - self._finalize_waiter: asyncio.Future[None] | None = None - self._audio_generation = 0 - self._final_generation = 0 + # The Router answers every input.commit with exactly one + # transcript.final, in order, so finals pair with commits FIFO. A + # final with no commit outstanding is the provider's own endpoint. + self._pending_commits: deque[_PendingCommit] = deque() + self._audio_since_commit = False self._ready = False self._closing = False @@ -120,8 +143,8 @@ async def connect(self) -> None: ) self.request_id = str(event.get("request_id", "")) self.route = dict(event.get("route", {})) - self._audio_generation = 0 - self._final_generation = 0 + self._pending_commits.clear() + self._audio_since_commit = False self._ready = True await self.on_event(event) self._listener_task = asyncio.create_task(self._listen()) @@ -156,40 +179,48 @@ async def send_audio(self, audio: bytes) -> None: raise SpekoRouterError( "relay_error", "Speko ASR session is not ready", retryable=True ) - if audio: - self._audio_generation += 1 await self._ws.send(audio) + if audio: + self._audio_since_commit = True - async def commit(self) -> None: + async def commit(self, context: Any = None) -> None: + """Finalize the audio sent since the last commit. + + Returns when the Router answers this commit. A provider final that + arrives first does not show where its audio ends, so any audio sent + since the last commit is committed and its own answer awaited. + """ if not self.is_ready or self._ws is None: raise SpekoRouterError( "relay_error", "Speko ASR session is not ready", retryable=True ) - if self._final_generation == self._audio_generation: + if not self._audio_since_commit: return - waiter = self._finalize_waiter - owner = waiter is None or waiter.done() - if owner: - waiter = asyncio.get_running_loop().create_future() - self._finalize_waiter = waiter + pending = _PendingCommit(context) + self._pending_commits.append(pending) + try: + await self._ws.send('{"type":"input.commit"}') + except BaseException: + # Never sent, so the Router owes it no answer. + self._pending_commits.remove(pending) + raise + self._audio_since_commit = False try: - if owner: - await self._ws.send('{"type":"input.commit"}') await asyncio.wait_for( - asyncio.shield(waiter), timeout=self.finalize_timeout_sec + asyncio.shield(pending.answered), + timeout=self.finalize_timeout_sec, ) except asyncio.TimeoutError as error: + # Stays outstanding: its late final must answer it, not the next. + pending.abandoned = True raise SpekoCommitTimeout( "request_timeout", "Timed out waiting for a final Speko transcript", retryable=True, ) from error - finally: - if owner: - if not waiter.done(): - waiter.cancel() - if self._finalize_waiter is waiter: - self._finalize_waiter = None + except asyncio.CancelledError: + pending.abandoned = True + raise async def close(self, *, drain: bool = True) -> None: self._closing = True @@ -234,29 +265,33 @@ async def _listen(self) -> None: await self.on_event(event) break - audio_generation = self._audio_generation + pending = None + if event_type == "transcript.final" and self._pending_commits: + pending = self._pending_commits.popleft() + if pending.abandoned: + event = {**event, LATE_COMMIT_CONTEXT: pending.context} + # Deliver before answering: the finalize must not complete + # ahead of the final that answers it. await self.on_event(event) - if event_type == "transcript.final": - self._final_generation = audio_generation - waiter = self._finalize_waiter - if waiter is not None and not waiter.done(): - waiter.set_result(None) + if pending is not None and not pending.answered.done(): + pending.answered.set_result(None) except SpekoRouterError as caught: error = caught except Exception as caught: # pragma: no cover - transport-specific error = SpekoRouterError("relay_error", str(caught), retryable=True) finally: self._ready = False - waiter = self._finalize_waiter - if waiter is not None and not waiter.done(): - waiter.set_exception( - error - or SpekoRouterError( - "relay_error", - "Speko ASR session closed before final transcript", - retryable=True, + while self._pending_commits: + pending = self._pending_commits.popleft() + if not pending.answered.done(): + pending.answered.set_exception( + error + or SpekoRouterError( + "relay_error", + "Speko ASR session closed before final transcript", + retryable=True, + ) ) - ) if not self._closing or error is not None: await self.on_disconnect(error) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py index 806f9a4a1c..bae8fa774b 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py @@ -33,6 +33,9 @@ class SpekoASRConfig(BaseModel): ready_timeout_sec: float = Field(default=10.0, gt=0, allow_inf_nan=False) finalize_timeout_sec: float = Field(default=10.0, gt=0, allow_inf_nan=False) buffer_duration_ms: int = 5000 + # 0 retries a lost connection until stop; N > 0 latches a fatal + # retry_exhausted after N failed reconnect attempts. + max_reconnect_attempts: int = Field(default=0, ge=0) @field_validator("sample_rate") @classmethod @@ -71,6 +74,7 @@ def update_params(self) -> None: "ready_timeout_sec", "finalize_timeout_sec", "buffer_duration_ms", + "max_reconnect_attempts", ): if name in values: setattr(self, name, values.pop(name)) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py index f225863d23..e4be8cb8b1 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -29,7 +29,12 @@ from ten_runtime import AsyncTenEnv, AudioFrame, Data from typing_extensions import override -from .client import SpekoASRClient, SpekoCommitTimeout, SpekoRouterError +from .client import ( + LATE_COMMIT_CONTEXT, + SpekoASRClient, + SpekoCommitTimeout, + SpekoRouterError, +) from .config import SpekoASRConfig, safe_error, safe_url # Disconnected audio stays in memory; bound it for high-rate input. @@ -46,7 +51,8 @@ class SpekoASRExtension(AsyncASRBaseExtension): - RECONNECT_DELAYS = (0.5, 1.0, 2.0, 4.0, 4.0) + # Backoff before each reconnect attempt; later attempts reuse the last. + RECONNECT_DELAYS = (0.5, 1.0, 2.0, 4.0) def __init__(self, name: str) -> None: super().__init__(name) @@ -69,6 +75,8 @@ def __init__(self, name: str) -> None: self._connection_epoch = 0 self._retired_clients: list[SpekoASRClient] = [] self._route: dict[str, Any] = {} + # Session of the frames held while disconnected; one session at a time. + self._buffered_session: str | None = None # Base _connection_lock -> swap -> I/O. Callbacks never acquire these # locks: connect/close/commit may themselves be awaiting a callback. self._swap_lock = asyncio.Lock() @@ -356,6 +364,21 @@ async def _consume_audio_frame( if not self.is_connected(): size = len(frame.get_buf()) policy = self.buffer_strategy() + session = self._frame_session_id(frame) + if session is None: + session = ( + self._buffered_session + if not self.buffered_frames.empty() + else self.session_id + ) + if ( + not self.buffered_frames.empty() + and session != self._buffered_session + ): + # One Router stream carries the replay: an earlier + # session's audio must not merge into this one's turn. + self._drop_buffered_frames() + self._buffered_session = session if isinstance(policy, ASRBufferConfigModeDiscard): self._record_dropped_audio(size) return @@ -371,6 +394,27 @@ async def _consume_audio_frame( self._record_dropped_audio(dropped_size) await super()._handle_audio_frame(ten_env, frame) + def _drop_buffered_frames(self) -> None: + while not self.buffered_frames.empty(): + frame = self.buffered_frames.get_nowait() + self._record_dropped_audio(len(frame.get_buf())) + self.buffered_frames_size = 0 + self._buffered_session = None + + @staticmethod + def _frame_session_id(frame: AudioFrame) -> str | None: + raw, _ = frame.get_property_to_json("metadata") + try: + metadata = json.loads(raw) if raw else None + except ValueError: + return None + if ( + isinstance(metadata, dict) + and metadata.get("session_id") is not None + ): + return str(metadata["session_id"]) + return None + def _record_dropped_audio(self, size: int) -> None: before = self._audio_duration_ms(self._dropped_audio_bytes) self._dropped_audio_bytes += size @@ -387,8 +431,13 @@ def _schedule_reconnect(self) -> None: self._reconnect_task = asyncio.create_task(self._reconnect()) async def _reconnect(self) -> None: - while self._reconnect_attempts < len(self.RECONNECT_DELAYS): - delay = self.RECONNECT_DELAYS[self._reconnect_attempts] + # 0 (the default) retries until stopped; only a non-retryable error + # latches. An operator may set a ceiling that latches retry_exhausted. + limit = self.config.max_reconnect_attempts if self.config else 0 + while not limit or self._reconnect_attempts < limit: + delay = self.RECONNECT_DELAYS[ + min(self._reconnect_attempts, len(self.RECONNECT_DELAYS) - 1) + ] async with self._swap_lock: await self._close_retired_clients() if self._connection_disabled(): @@ -485,10 +534,15 @@ async def finalize(self, session_id: str | None) -> None: try: async with self._io_lock: if not self.is_connected(): + # The finalize ends this turn; its held audio must not be + # replayed into a later one. + self._drop_buffered_frames() return client = self.client assert client is not None - self._commit_task = asyncio.create_task(client.commit()) + self._commit_task = asyncio.create_task( + client.commit(self._result_context()) + ) try: await self._commit_task acknowledged = True @@ -519,26 +573,32 @@ async def finalize(self, session_id: str | None) -> None: if error is not None: await self._reset_connection(error, epoch) if not self.stopped: - if acknowledged and self.last_finalize_time is not None: - # No non-empty final consumed the finalize: the provider - # had already finalized the turn, or the Router acked - # with an empty final. TTLW is the latency to that ack. - elapsed = ( - asyncio.get_running_loop().time() - - self.last_finalize_time - ) - await self._send_metrics_ttlw(int(elapsed * 1000)) - self.last_finalize_time = None - # send_asr_finalize_end reads mutable base metadata. Snapshot - # only while reporting, after commit releases its I/O lock. + # Reports read mutable base metadata and session_id. Attribute + # them to the finalize only while reporting, after commit + # releases its I/O lock. async with self._metadata_lock: - previous = self.metadata - if self._finalize_context is not None: - self.metadata = self._finalize_context + previous = self.metadata, self.session_id + context = self._finalize_context + if context is not None: + self.metadata = context + self.session_id = context.get( + "session_id", self.session_id + ) try: + if acknowledged and self.last_finalize_time is not None: + # No non-empty final consumed the finalize: the + # provider had already finalized the turn, or the + # Router answered with an empty final. TTLW is the + # latency to that answer. + elapsed = ( + asyncio.get_running_loop().time() + - self.last_finalize_time + ) + await self._send_metrics_ttlw(int(elapsed * 1000)) + self.last_finalize_time = None await self.send_asr_finalize_end() finally: - self.metadata = previous + self.metadata, self.session_id = previous async def _reset_connection( self, @@ -573,13 +633,16 @@ async def _reset_connection( async def _on_router_disconnect( self, error: SpekoRouterError | None ) -> None: - await self._reset_connection( - error - or SpekoRouterError( - "relay_error", "Speko ASR connection closed", retryable=True - ), - self._connection_epoch, - ) + if error is not None: + await self._reset_connection(error, self._connection_epoch) + return + # A completed close handshake (1000/1001, or session.closed) is not a + # failure: report the disconnect and recover without an asr_error. + if self.stopped: + return + self._detach_client() + await self.on_disconnected(message="Speko ASR session closed") + self._schedule_reconnect() async def _on_router_event(self, event: dict[str, Any]) -> None: epoch = self._connection_epoch @@ -605,6 +668,7 @@ async def _on_router_event(self, event: dict[str, Any]) -> None: while not self.audio_frames_queue.empty(): pending.append(self.audio_frames_queue.get_nowait()) self.buffered_frames_size = 0 + self._buffered_session = None self._audio_queue_drained.clear() for frame in pending: self.audio_frames_queue.put_nowait(frame) @@ -677,8 +741,15 @@ async def _send_transcript( async with self._metadata_lock: if self.stopped or epoch != self._connection_epoch: return - previous = self.metadata - metadata = copy.deepcopy(previous or {}) + previous = self.metadata, self.session_id + late = event.get(LATE_COMMIT_CONTEXT) + if isinstance(late, dict): + # Answers a commit whose finalize already ended: keep it with + # that turn, not whichever session is streaming now. + self.session_id = late.get("session_id") + metadata = copy.deepcopy(late.get("metadata") or {}) + else: + metadata = copy.deepcopy(self.metadata or {}) info = metadata.setdefault("asr_info", {}) if not isinstance(info, dict): info = metadata["asr_info"] = {} @@ -701,7 +772,7 @@ async def _send_transcript( ) ) finally: - self.metadata = previous + self.metadata, self.session_id = previous if final: self._final_cursor_ms = max(self._final_cursor_ms, end_ms) @@ -721,6 +792,13 @@ async def _report_router_error( ) ) + def _result_context(self) -> dict[str, Any]: + """Attribution for results answering the commit about to be sent.""" + return { + "metadata": copy.deepcopy(self.metadata), + "session_id": self.session_id, + } + def _configure_frame(self) -> dict[str, Any]: assert self.config is not None frame: dict[str, Any] = { diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json b/ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json index 19b267e41c..dd5de18923 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json @@ -52,7 +52,8 @@ "options": {"type": "object", "properties": {}}, "ready_timeout_sec": {"type": "float64"}, "finalize_timeout_sec": {"type": "float64"}, - "buffer_duration_ms": {"type": "int32"} + "buffer_duration_ms": {"type": "int32"}, + "max_reconnect_attempts": {"type": "int32"} } } } diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/property.json b/ai_agents/agents/ten_packages/extension/speko_asr_python/property.json index 0a0d7c7ed6..9a8e0dcb66 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/property.json +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/property.json @@ -15,6 +15,7 @@ "options": {}, "ready_timeout_sec": 10.0, "finalize_timeout_sec": 10.0, - "buffer_duration_ms": 5000 + "buffer_duration_ms": 5000, + "max_reconnect_attempts": 0 } } diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py index da7487b921..aaacee48a1 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py @@ -4,6 +4,7 @@ import pytest from speko_asr_python.client import ( + LATE_COMMIT_CONTEXT, SpekoASRClient, SpekoCommitTimeout, SpekoRouterError, @@ -163,9 +164,10 @@ def test_websocket_url_preserves_base_path(): @pytest.mark.asyncio -async def test_timeout_does_not_poison_next_commit(): +async def test_late_final_answers_its_timed_out_commit_not_the_next(): websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) - client = make_client([], []) + events = [] + client = make_client(events, []) client.finalize_timeout_sec = 0.01 with patch( "speko_asr_python.client.websockets.connect", @@ -174,49 +176,63 @@ async def test_timeout_does_not_poison_next_commit(): await client.connect() await client.send_audio(b"\x00\x00") with pytest.raises(SpekoCommitTimeout, match="Timed out"): - await client.commit() - assert client._finalize_waiter is None + await client.commit({"turn": 1}) # A missing acknowledgement is not a transport failure. assert client.is_ready - next_commit = asyncio.create_task(client.commit()) + client.finalize_timeout_sec = 1 + await client.send_audio(b"\x01\x01") + second = asyncio.create_task(client.commit({"turn": 2})) await asyncio.sleep(0) websocket.messages.put_nowait( - json.dumps({"type": "transcript.final", "text": "ok"}) + json.dumps({"type": "transcript.final", "text": "late"}) + ) + for _ in range(20): + if events[-1].get("text") == "late": + break + await asyncio.sleep(0) + assert events[-1][LATE_COMMIT_CONTEXT] == {"turn": 1} + assert not second.done() + websocket.messages.put_nowait( + json.dumps({"type": "transcript.final", "text": "second"}) ) - await asyncio.wait_for(next_commit, 1) + await asyncio.wait_for(second, 1) + assert LATE_COMMIT_CONTEXT not in events[-1] + assert websocket.sent.count('{"type":"input.commit"}') == 2 await client.close(drain=False) @pytest.mark.asyncio -async def test_auto_final_before_commit_and_new_audio(): +async def test_audio_sent_before_a_provider_final_is_still_committed(): websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) - received = asyncio.Event() - client = make_client([], []) - client.on_event = AsyncMock( - side_effect=lambda event: ( - received.set() if event["type"] == "transcript.final" else None - ) - ) + events = [] + client = make_client(events, []) with patch( "speko_asr_python.client.websockets.connect", AsyncMock(return_value=websocket), ): await client.connect() await client.send_audio(b"first") + # The next utterance starts before the provider's own final for the + # first one reaches the client. + await client.send_audio(b"next") websocket.messages.put_nowait( json.dumps({"type": "transcript.final", "text": "first"}) ) - await asyncio.wait_for(received.wait(), 1) - await client.commit() - assert '{"type":"input.commit"}' not in websocket.sent - await client.send_audio(b"next") + for _ in range(20): + if events[-1].get("text") == "first": + break + await asyncio.sleep(0) commit = asyncio.create_task(client.commit()) await asyncio.sleep(0) + assert websocket.sent[-1] == '{"type":"input.commit"}' assert not commit.done() websocket.messages.put_nowait( json.dumps({"type": "transcript.final", "text": "next"}) ) await asyncio.wait_for(commit, 1) + # Nothing new was sent since that commit, so nothing to finalize. + await asyncio.wait_for(client.commit(), 0.1) + assert websocket.sent.count('{"type":"input.commit"}') == 1 await client.close(drain=False) @@ -256,7 +272,8 @@ async def test_failed_commit_send_clears_waiter(): websocket.send = AsyncMock(side_effect=OSError("socket closed")) with pytest.raises(OSError): await client.commit() - assert client._finalize_waiter is None + assert not client._pending_commits + assert client._audio_since_commit await client.close(drain=False) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py index 04cd55ba84..1190255fda 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py @@ -171,7 +171,7 @@ async def test_finalize_cancelled_by_shutdown_does_not_emit_after_stop(): extension, client = make_extension() entered = asyncio.Event() - async def commit(): + async def commit(_context=None): entered.set() await asyncio.Future() diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py index 308fb0756a..d66d952b97 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py @@ -8,6 +8,7 @@ import pytest from ten_ai_base.message import ModuleErrorCode from ten_runtime import Data +from websockets.exceptions import ConnectionClosedError from speko_asr_python.client import SpekoCommitTimeout, SpekoRouterError from speko_asr_python.config import SpekoASRConfig @@ -103,7 +104,7 @@ async def test_concurrent_finalize_echoes_each_request_and_blocks_later_audio(): await ext._handle_audio_frame(ext.ten_env, audio()) entered, release = asyncio.Event(), asyncio.Event() - async def commit(): + async def commit(_context=None): entered.set() await release.wait() await ext._on_router_event({"type": "transcript.final", "text": "done"}) @@ -211,7 +212,7 @@ async def test_empty_final_acknowledges_finalize_without_a_result(): ext, events = extension_and_events() await ext._handle_audio_frame(ext.ten_env, audio()) - async def commit(): + async def commit(_context=None): await ext._on_router_event({"type": "transcript.final", "text": ""}) ext.client.commit.side_effect = commit @@ -235,15 +236,15 @@ def ttlw_metrics(events): @pytest.mark.asyncio @pytest.mark.parametrize( - "final_text", - [None, "", "done"], - ids=["short_circuited_commit", "empty_final_ack", "final_result"], + "final_text,session", + [(None, "turn-one"), ("", "turn-one"), ("done", "audio-session")], + ids=["no_commit_needed", "empty_final_ack", "final_result"], ) -async def test_acknowledged_finalize_reports_ttlw_once(final_text): +async def test_acknowledged_finalize_reports_ttlw_once(final_text, session): ext, events = extension_and_events() await ext._handle_audio_frame(ext.ten_env, audio()) - async def commit(): + async def commit(_context=None): if final_text is not None: await ext._on_router_event( {"type": "transcript.final", "text": final_text} @@ -254,7 +255,11 @@ async def commit(): ttlw = ttlw_metrics(events) assert len(ttlw) == 1 assert ttlw[0]["metrics"]["ttlw"] >= 0 - assert ttlw[0]["metadata"]["session_id"] == "audio-session" + # Without a result, TTLW belongs to the finalize it measures, like its + # acknowledgement; with one, to that result's session. + assert ttlw[0]["metadata"]["session_id"] == session + end = payloads(events, "asr_finalize_end")[0] + assert end["metadata"]["session_id"] == "turn-one" kinds = [ "ttlw" if kind == "metrics" and "ttlw" in payload["metrics"] else kind for kind, payload in events @@ -390,6 +395,125 @@ async def connect(*args, **kwargs): assert rejected.await_count == 1 +class _RouterSocket: + """A Router socket that is ready, streams queued events, then closes.""" + + def __init__(self, failure=None): + self.failure = failure + self.events = asyncio.Queue() + self.sent = [] + + async def send(self, value): + self.sent.append(value) + + async def recv(self): + return json.dumps({"type": "session.ready"}) + + def __aiter__(self): + return self + + async def __anext__(self): + event = await self.events.get() + if event is None: + if self.failure is not None: + raise self.failure + raise StopAsyncIteration + return json.dumps(event) + + async def close(self): + pass + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + [None, ConnectionClosedError(None, None)], + ids=["normal_close", "abnormal_close"], +) +async def test_only_an_abnormal_close_reports_an_error(failure): + ext, events = extension_and_events() + ext.client = None + ext._schedule_reconnect = MagicMock() + socket = _RouterSocket(failure) + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=socket), + ): + await ext.start_connection() + socket.events.put_nowait(None) + for _ in range(50): + if ext._schedule_reconnect.called: + break + await asyncio.sleep(0) + ext._schedule_reconnect.assert_called_once() + statuses = [ + (status["current"], status["code"]) + for status in payloads(events, "connection_status_changed") + ] + if failure is None: + assert statuses[-1] == ("disconnected", 0) + assert not payloads(events, "error") + else: + code = int(ModuleErrorCode.NON_FATAL_ERROR.value) + assert statuses[-1] == ("disconnected", code) + assert [error["code"] for error in payloads(events, "error")] == [code] + await ext.on_stop(ext.ten_env) + + +@pytest.mark.asyncio +async def test_late_final_keeps_its_turn_and_next_finalize_waits_for_its_own(): + ext, events = extension_and_events() + ext.client = None + ext.config.finalize_timeout_sec = 0.05 + socket = _RouterSocket() + + async def stream(session): + await ext.on_audio_frame(ext.ten_env, audio({"session_id": session})) + await ext._handle_audio_frame( + ext.ten_env, ext.audio_frames_queue.get_nowait() + ) + + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=socket), + ): + await ext.start_connection() + await stream("one") + # Turn one's commit times out; its final is still on the way. + await ext.on_data(ext.ten_env, finalize_data("one", "one")) + ext.client.finalize_timeout_sec = 1 + await stream("two") + second = asyncio.create_task( + ext.on_data(ext.ten_env, finalize_data("two", "two")) + ) + for _ in range(50): + if socket.sent.count('{"type":"input.commit"}') == 2: + break + await asyncio.sleep(0) + socket.events.put_nowait({"type": "transcript.final", "text": "late"}) + for _ in range(50): + if payloads(events, "asr_result"): + break + await asyncio.sleep(0) + assert not second.done() + socket.events.put_nowait({"type": "transcript.final", "text": "own"}) + await asyncio.wait_for(second, 1) + results = payloads(events, "asr_result") + assert [ + (item["text"], item["metadata"]["session_id"]) for item in results + ] == [ + ("late", "one"), + ("own", "two"), + ] + kinds = [ + payload.get("finalize_id") or payload.get("text") + for kind, payload in events + if kind in {"asr_result", "asr_finalize_end"} + ] + assert kinds == ["one", "late", "own", "two"] + await ext.on_stop(ext.ten_env) + + @pytest.mark.asyncio async def test_retryable_setup_failure_reports_non_fatal_and_reconnects(): ext, events = extension_and_events() @@ -494,11 +618,7 @@ async def test_invalid_configuration_is_latched_before_any_connect_attempt( assert "private-value" not in json.dumps(events) -@pytest.mark.asyncio -async def test_bounded_reconnect_escalates_and_success_resets_counter(): - ext, events = extension_and_events() - ext.client = None - ext.start_connection = AsyncMock() +async def _reconnect_with_recorded_delays(ext): real_sleep = asyncio.sleep delays = [] @@ -508,14 +628,42 @@ async def sleep(delay): with patch("speko_asr_python.extension.asyncio.sleep", side_effect=sleep): await ext._reconnect() - assert delays == [0.5, 1, 2, 4, 4] - assert ext.start_connection.await_count == 5 - assert ext._reconnect_attempts == 5 - assert payloads(events, "error")[-1]["code"] == int( - ModuleErrorCode.FATAL_ERROR.value - ) + return delays + + +@pytest.mark.asyncio +async def test_default_reconnect_is_unlimited_with_capped_backoff(): + ext, events = extension_and_events() + ext.client = None + + async def connect(): + # The Router comes back on the eighth attempt. + if ext.start_connection.await_count == 8: + ext.client = MagicMock(is_ready=True) + + ext.start_connection = AsyncMock(side_effect=connect) + delays = await _reconnect_with_recorded_delays(ext) + assert delays == [0.5, 1, 2, 4, 4, 4, 4, 4] + assert ext.is_connected() + assert ext._permanent_error is None + assert not payloads(events, "error") + + +@pytest.mark.asyncio +async def test_configured_reconnect_ceiling_latches_and_success_resets(): + ext, events = extension_and_events() + ext.config = SpekoASRConfig(api_key="key", max_reconnect_attempts=2) + ext.client = None + ext.start_connection = AsyncMock() + delays = await _reconnect_with_recorded_delays(ext) + assert delays == [0.5, 1] + assert ext.start_connection.await_count == 2 + errors = payloads(events, "error") + assert len(errors) == 1 + assert errors[0]["code"] == int(ModuleErrorCode.FATAL_ERROR.value) + assert errors[0]["vendor_info"]["code"] == "retry_exhausted" await ext._ensure_connection() - assert ext.start_connection.await_count == 5 + assert ext.start_connection.await_count == 2 ext._permanent_error = None ext.client = MagicMock(is_ready=True) ext.on_connected = AsyncMock() @@ -528,7 +676,7 @@ async def test_stop_cancels_commit_and_never_reports_after_shutdown(): ext, events = extension_and_events() started = asyncio.Event() - async def commit(): + async def commit(_context=None): started.set() await asyncio.Future() @@ -602,9 +750,7 @@ async def connect(): @pytest.mark.asyncio -async def test_buffered_frames_keep_session_metadata_and_dump_exact_ingress( - tmp_path, -): +async def test_disconnected_buffer_replays_only_the_latest_session(tmp_path): ext, events = extension_and_events() ext.config.dump = True ext.config.dump_path = str(tmp_path / "audio.pcm") @@ -613,31 +759,60 @@ async def test_buffered_frames_keep_session_metadata_and_dump_exact_ingress( client.is_ready = False ext.auto_connect = False for session in ("old", "new"): - frame = audio({"session_id": session}) - await ext.on_audio_frame(ext.ten_env, frame) + await ext.on_audio_frame(ext.ten_env, audio({"session_id": session})) await ext._handle_audio_frame( ext.ten_env, ext.audio_frames_queue.get_nowait() ) + # The old session's held audio is dropped into the timeline, not merged + # into the stream that now carries the new session. + assert ext.buffered_frames.qsize() == 1 + assert ext._dropped_audio_bytes == 32000 client.is_ready = True ext.on_connected = AsyncMock() await ext._on_router_event({"type": "session.ready"}) - for _ in range(2): - await ext._handle_audio_frame( - ext.ten_env, ext.audio_frames_queue.get_nowait() - ) - await ext._on_router_event( - {"type": "transcript.final", "text": "hello"} - ) + await ext._handle_audio_frame( + ext.ten_env, ext.audio_frames_queue.get_nowait() + ) + assert ext.audio_frames_queue.empty() + client.send_audio.assert_awaited_once() + # The provider answers only after all replayed audio has gone out. + await ext._on_router_event({"type": "transcript.final", "text": "hello"}) assert [ item["metadata"]["session_id"] for item in payloads(events, "asr_result") - ] == ["old", "new"] + ] == ["new"] await ext.stop_connection() - assert ext.audio_dumper is not None await ext.on_deinit(ext.ten_env) + # The dump keeps exact ingress, including the dropped frame. assert (tmp_path / "audio.pcm").read_bytes() == bytes(64000) +@pytest.mark.asyncio +async def test_disconnected_finalize_drops_the_finished_turns_held_audio(): + ext, events = extension_and_events() + client = ext.client + client.is_ready = False + ext.auto_connect = False + await ext.on_audio_frame(ext.ten_env, audio({"session_id": "turn"})) + await ext._handle_audio_frame( + ext.ten_env, ext.audio_frames_queue.get_nowait() + ) + assert ext.buffered_frames.qsize() == 1 + await ext.on_data(ext.ten_env, finalize_data("done", "turn")) + assert [ + item["finalize_id"] for item in payloads(events, "asr_finalize_end") + ] == ["done"] + assert ext.buffered_frames.empty() + assert ext.buffered_frames_size == 0 + assert ext._dropped_audio_bytes == 32000 + client.is_ready = True + ext.on_connected = AsyncMock() + await ext._on_router_event({"type": "session.ready"}) + assert ext.audio_frames_queue.empty() + client.send_audio.assert_not_awaited() + client.commit.assert_not_awaited() + + @pytest.mark.asyncio async def test_dropped_audio_and_fractional_frames_have_correct_timeline(): ext, _ = extension_and_events() @@ -684,6 +859,7 @@ async def test_disconnected_buffer_cap_drops_oldest_frames_into_timeline(): {"routing": {"mode": "wrong"}}, {"buffer_duration_ms": 30001}, {"buffer_duration_ms": -1}, + {"max_reconnect_attempts": -1}, ], ) def test_invalid_config_values(params): @@ -745,7 +921,7 @@ async def test_metrics_have_session_metadata_and_timeline_excludes_silence(): ext.audio_timeline.add_silence_audio(500) await ext._handle_audio_frame(ext.ten_env, audio()) - async def commit(): + async def commit(_context=None): await ext._on_router_event( { "type": "transcript.final", @@ -842,7 +1018,7 @@ async def connect(): async def send(_audio): order.append(ext.session_id) - async def commit(): + async def commit(_context=None): order.append("commit") await kwargs["on_event"]( {"type": "transcript.final", "text": "buffered speech"} @@ -858,9 +1034,9 @@ async def commit(): with patch("speko_asr_python.extension.SpekoASRClient", factory): connection = asyncio.create_task(ext._ensure_connection()) await entered.wait() - for session in ("first", "second"): + for _ in range(2): await ext.on_audio_frame( - ext.ten_env, audio({"session_id": session}) + ext.ten_env, audio({"session_id": "turn"}) ) await ext._audio_queue_drained.wait() assert ext.buffered_frames.qsize() == 2 @@ -879,7 +1055,7 @@ async def commit(): asyncio.gather(connection, finalize, later), 1 ) await ext._audio_queue_drained.wait() - assert order == ["first", "second", "commit", "later"] + assert order == ["turn", "turn", "commit", "later"] assert len(payloads(events, "asr_result")) == 1 assert payloads(events, "asr_finalize_end")[0]["finalize_id"] == "early" finally: @@ -897,7 +1073,7 @@ async def send(_audio): await asyncio.sleep(0) order.append(ext.session_id) - async def commit(): + async def commit(_context=None): order.append("commit") ext.client.send_audio.side_effect = send From b6636e8c1057dd3814823f5fc9c15ead1232e1e6 Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 06:41:15 +0500 Subject: [PATCH 14/17] fix(speko_llm2): forward the item schema of array tool parameters Array tool parameters lost their items schema, so MCP-style tools with array arguments reached the model without element types. Forward items like the other LLM2 adapters do. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_llm2_python/extension.py | 10 ++- .../speko_llm2_python/tests/test_extension.py | 68 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py index b1f3a54b2e..5bc42d725d 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py @@ -3,6 +3,7 @@ # Licensed under the Apache License, Version 2.0. # See the LICENSE file for more information. # +import copy import json import time import uuid @@ -319,10 +320,17 @@ def _convert_tool(tool: LLMToolMetadata) -> dict[str, Any]: properties: dict[str, Any] = {} required: list[str] = [] for parameter in tool.parameters: - properties[parameter.name] = { + schema: dict[str, Any] = { "type": parameter.type, "description": parameter.description, } + # MCP tools describe array elements, possibly nested; providers + # may reject an array schema without them. Newer ten_ai_base + # parameters carry the field, older ones do not. + items = getattr(parameter, "items", None) + if parameter.type == "array" and items: + schema["items"] = copy.deepcopy(items) + properties[parameter.name] = schema if parameter.required: required.append(parameter.name) return { diff --git a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py index 98bf1b9dd1..e3f4038988 100644 --- a/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py @@ -6,6 +6,7 @@ import pytest from ten_ai_base.message import ModuleErrorCode from ten_ai_base.struct import LLMRequest +from ten_ai_base.types import LLMToolMetadata, LLMToolMetadataParameter from speko_llm2_python.client import SpekoLLMClient, SSEEvent from speko_llm2_python.config import SpekoLLM2Config @@ -189,6 +190,73 @@ async def test_failures_emit_ten_error_with_speko_vendor_info( await extension.client._client.aclose() +class MCPToolParameter(LLMToolMetadataParameter): + """A tool parameter as MCP supplies it, with its array element schema.""" + + items: dict | None = None + + +@pytest.mark.asyncio +async def test_array_tool_parameter_keeps_its_item_schema_in_the_request(): + sent = [] + + def handler(request): + sent.append(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "id": "response", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + } + ], + }, + ) + + extension = _configured_extension(handler) + items = { + "type": "object", + "properties": {"tag": {"type": "string"}}, + "required": ["tag"], + } + tool = LLMToolMetadata( + name="search", + description="Search notes", + parameters=[ + MCPToolParameter( + name="filters", + type="array", + description="Tag filters", + required=True, + items=items, + ), + LLMToolMetadataParameter( + name="query", type="string", description="Text", required=True + ), + ], + ) + request = LLMRequest( + request_id="request", messages=[], streaming=False, tools=[tool] + ) + _ = [ + item + async for item in extension.on_call_chat_completion( + extension.ten_env, request + ) + ] + properties = sent[0]["tools"][0]["parameters"]["properties"] + assert properties["filters"] == { + "type": "array", + "description": "Tag filters", + "items": items, + } + assert "items" not in properties["query"] + await extension.client._client.aclose() + + def test_config_log_redacts_url(): config = SpekoLLM2Config( api_key="test-secret-key-long", From ce296e0dc086b34bd5b6f2b4aa54eb5dcbe06129 Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 06:41:16 +0500 Subject: [PATCH 15/17] fix(speko_tts2): skip empty audio frames and dump only emitted PCM - A zero-length binary frame no longer starts audio or stamps TTFB. - The dump holds exactly the PCM sent to the graph. The unaligned tail the base keeps as leftover is not written. - The local suite records frame bytes and format. It covers two output configurations, back-to-back requests keeping their own audio, metadata and audio_end, and the real dump equal to the emitted PCM on completion, flush and error. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_tts2_python/client.py | 3 + .../extension/speko_tts2_python/extension.py | 12 +- .../tests/test_audio_accounting.py | 139 +++++++++++++++++- .../speko_tts2_python/tests/test_client.py | 36 +++++ 4 files changed, 177 insertions(+), 13 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py index 0d7b799108..83f2268860 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py @@ -186,6 +186,9 @@ async def stream_text(self, text: str) -> AsyncIterator[SpekoTTSEvent]: if self._cancelled: return if isinstance(raw, bytes): + if not raw: + # No audio yet: first audio and TTFB wait for real PCM. + continue if sequence is None: raise SpekoRouterError( "relay_error", diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py index 9a2098ddab..3315b7f2a8 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py @@ -279,14 +279,14 @@ async def _stream_text(self, text: str, request_id: str) -> None: continue await self._ensure_audio_start() self.metrics_add_recv_audio_chunks(audio) - await self._write_dump(request_id, audio) # The base emits whole frames and keeps an odd tail for the - # next chunk; count only bytes that left as frames. - pending = len(self.leftover_bytes) + # next chunk; count and dump only bytes that left as frames. + combined = self.leftover_bytes + audio await self.send_tts_audio_data(audio) - self._total_audio_bytes += ( - pending + len(audio) - len(self.leftover_bytes) - ) + emitted = combined[: len(combined) - len(self.leftover_bytes)] + self._total_audio_bytes += len(emitted) + if emitted: + await self._write_dump(request_id, emitted) elif event.type == SpekoTTSEventType.USAGE: if isinstance(event.value, dict): self._router_usage = event.value diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py index 362e050129..1321091964 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py @@ -58,9 +58,29 @@ async def _data(self, data): async def _frame(self, frame): buffer = frame.lock_buf() try: - self.events.append(("frame", len(buffer))) + pcm = bytes(buffer) finally: frame.unlock_buf(buffer) + raw, _ = frame.get_property_to_json("metadata") + self.events.append( + ( + "frame", + { + "pcm": pcm, + "sample_rate": frame.get_sample_rate(), + "channels": frame.get_number_of_channels(), + "bytes_per_sample": frame.get_bytes_per_sample(), + "samples_per_channel": frame.get_samples_per_channel(), + "metadata": json.loads(raw) if raw else {}, + }, + ) + ) + + def frame_sizes(self): + return [len(frame["pcm"]) for frame in self.named("frame")] + + def pcm(self): + return b"".join(frame["pcm"] for frame in self.named("frame")) def named(self, name): return [payload for kind, payload in self.events if kind == name] @@ -104,6 +124,8 @@ async def stream_text(self, _text): yield SpekoTTSEvent(SpekoTTSEventType.TTFB, args[0]) elif step == "audio": yield SpekoTTSEvent(SpekoTTSEventType.AUDIO, b"\x01" * args[0]) + elif step == "pcm": + yield SpekoTTSEvent(SpekoTTSEventType.AUDIO, args[0]) elif step == "usage": self.usage = args[0] yield SpekoTTSEvent(SpekoTTSEventType.USAGE, args[0]) @@ -127,7 +149,7 @@ async def cancel(self): self.is_ready = False -def text_input(text, end, request_id="request"): +def text_input(text, end, request_id="request", metadata=None): data = Data.create("tts_text_input") data.set_property_from_json( None, @@ -135,12 +157,17 @@ def text_input(text, end, request_id="request"): request_id=request_id, text=text, text_input_end=end, - metadata={"session_id": "session"}, + metadata=metadata or {"session_id": "session"}, ).model_dump_json(), ) return data +def pcm(size, seed): + """Distinct bytes per stream, so mixed-up audio cannot compare equal.""" + return bytes((seed + index) % 251 for index in range(size)) + + def flush(): data = Data.create("tts_flush") data.set_property_from_json(None, json.dumps({"flush_id": "flush"})) @@ -156,12 +183,12 @@ async def poll(): class Harness: - def __init__(self, *streams): + def __init__(self, *streams, **config): self.clock = FakeClock() self.ten_env = RecordingTenEnv() self.script = Script(self.clock, *streams) self.extension = SpekoTTS2Extension("speko") - self.extension.config = SpekoTTS2Config(api_key="test-key") + self.extension.config = SpekoTTS2Config(api_key="test-key", **config) self.extension.ten_env = self.ten_env self.script.extension = self.extension self._patches = [ @@ -230,7 +257,7 @@ async def test_success_order_duration_and_interval(): end = env.index(lambda kind, _: kind == "tts_audio_end") usage = env.index(is_usage) assert start < ttfb < first_frame < end < usage - assert env.named("frame") == [HUNDRED_MS, HUNDRED_MS] + assert env.frame_sizes() == [HUNDRED_MS, HUNDRED_MS] assert env.events[ttfb][1]["metrics"]["ttfb"] == 120 (payload,) = harness.ends() assert payload["reason"] == TTSAudioEndReason.REQUEST_END @@ -361,6 +388,104 @@ async def test_odd_sized_chunks_count_only_emitted_frames(): await until(lambda: harness.ends() and harness.idle()) # The odd byte waits in the base's leftover buffer for the next chunk. assert harness.script.probes == [HUNDRED_MS, 2 * HUNDRED_MS] - assert harness.ten_env.named("frame") == [HUNDRED_MS, HUNDRED_MS] + assert harness.ten_env.frame_sizes() == [HUNDRED_MS, HUNDRED_MS] + (payload,) = harness.ends() + assert payload["request_total_audio_duration_ms"] == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sample_rate,channels", [(24000, 1), (16000, 2)]) +async def test_emitted_pcm_and_frame_format_follow_the_output_config( + sample_rate, channels +): + frame_bytes = 2 * channels + tenth = sample_rate * frame_bytes // 10 + audio = pcm(2 * tenth, seed=7) + # The first chunk ends one byte into a sample frame. + split = tenth + 1 + async with Harness( + [("ttfb", 50), ("pcm", audio[:split]), ("pcm", audio[split:])], + sample_rate=sample_rate, + channels=channels, + ) as harness: + await harness.send(text_input("hello", True)) + await until(lambda: harness.ends() and harness.idle()) + env = harness.ten_env + assert env.pcm() == audio + for frame in env.named("frame"): + assert ( + frame["sample_rate"], + frame["channels"], + frame["bytes_per_sample"], + ) == (sample_rate, channels, 2) + assert frame["samples_per_channel"] * frame_bytes == len(frame["pcm"]) (payload,) = harness.ends() assert payload["request_total_audio_duration_ms"] == 200 + + +@pytest.mark.asyncio +async def test_back_to_back_requests_keep_their_own_audio_and_end(): + first, second = pcm(HUNDRED_MS, seed=1), pcm(2 * HUNDRED_MS, seed=2) + async with Harness( + [("ttfb", 40), ("pcm", first[:1001]), ("pcm", first[1001:])], + [("ttfb", 60), ("pcm", second)], + ) as harness: + # The second request is queued while the first one streams. + await harness.send(text_input("one", True, "r1", {"session_id": "s1"})) + await harness.send(text_input("two", True, "r2", {"session_id": "s2"})) + await until(lambda: len(harness.ends()) == 2 and harness.idle()) + audio = {"r1": [], "r2": []} + current = None + for kind, payload in harness.ten_env.events: + if kind == "tts_audio_start": + assert current is None + current = payload["request_id"] + elif kind == "frame": + audio[current].append(payload) + elif kind == "tts_audio_end": + assert payload["request_id"] == current + current = None + assert b"".join(frame["pcm"] for frame in audio["r1"]) == first + assert b"".join(frame["pcm"] for frame in audio["r2"]) == second + for request, session in (("r1", "s1"), ("r2", "s2")): + assert { + frame["metadata"].get("session_id") for frame in audio[request] + } == {session} + ends = harness.ends() + assert [payload["request_id"] for payload in ends] == ["r1", "r2"] + assert [payload["request_total_audio_duration_ms"] for payload in ends] == [ + 100, + 200, + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal", ["completion", "flush", "error"]) +async def test_dump_holds_exactly_the_emitted_pcm(tmp_path, terminal): + # The odd last byte stays in the base's leftover and is never emitted. + audio = pcm(HUNDRED_MS + 1, seed=3) + tail = { + "completion": [], + "flush": [("block",)], + "error": [ + ( + "error", + SpekoRouterError("provider_error", "boom", retryable=True), + ) + ], + }[terminal] + async with Harness( + [("ttfb", 30), ("pcm", audio), *tail], + dump=True, + dump_path=str(tmp_path), + ) as harness: + await harness.send(text_input("hello", True)) + if terminal == "flush": + await until(harness.script.blocked.is_set) + await harness.send(flush()) + await until(lambda: harness.ten_env.named("tts_flush_end")) + else: + await until(lambda: harness.ends() and harness.idle()) + emitted = harness.ten_env.pcm() + assert emitted == audio[:-1] + assert (tmp_path / "speko_tts_request.pcm").read_bytes() == emitted diff --git a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py index 20c2596e87..2e7c66dcca 100644 --- a/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py @@ -332,3 +332,39 @@ def monotonic(): ttfb = [event for event in events if event.type == SpekoTTSEventType.TTFB] assert [event.value for event in ttfb] == [250] await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_empty_binary_frame_neither_starts_audio_nor_stamps_ttfb(): + websocket = FakeWebSocket( + json.dumps({"type": "session.ready", "route": {}}), + json.dumps({"type": "utterance.started", "sequence": 1}), + b"", + b"pcm", + json.dumps({"type": "utterance.done", "sequence": 1}), + ) + # Before append; utterance.started; the empty frame; the PCM frame. + readings = iter([10.0, 10.1, 10.2, 10.4]) + + class Clock: + @staticmethod + def monotonic(): + return next(readings, 99.0) + + client = make_client() + with patch( + "speko_tts2_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + await client.connect() + with patch("speko_tts2_python.client.time", Clock): + events = [event async for event in client.stream_text("hello")] + assert [event.type for event in events] == [ + SpekoTTSEventType.TTFB, + SpekoTTSEventType.AUDIO, + SpekoTTSEventType.DONE, + ] + # Measured to the first non-empty PCM, not the empty frame. + assert events[0].value == 400 + assert events[1].value == b"pcm" + await client.close(drain=False) From 12082c6f925f614a86eaaa341c87b5d9d04a839c Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 07:09:19 +0500 Subject: [PATCH 16/17] fix(speko_asr): correlate commit answers by commit_id and bound replay Address the third PR #2401 review of speko_asr_python: - Each input.commit carries a fresh commit_id. On a Router that stamps answers (commit_ids in session.ready, or a first answer carrying its commit_id), only the final with a commit's own id answers it. A final without an id is the provider's own endpoint and is emitted as a normal result, so an auto-final in flight when a commit is sent no longer completes that commit. A late answer to a timed-out commit is reported under that turn's metadata and resolves no other commit. A permanently missing answer shifts nothing, so the session is kept. - A Router that does not stamp answers still pairs them in order. There a commit timeout raises a plain error and the session is reset, so later answers cannot be paired one behind. - On session.ready, frames still queued are held to the same session boundary as the disconnected buffer before replay. An older session's held audio is recorded as dropped instead of sharing the stream. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_asr_python/README.md | 26 ++- .../extension/speko_asr_python/client.py | 69 +++++-- .../extension/speko_asr_python/extension.py | 36 +++- .../speko_asr_python/tests/test_client.py | 188 +++++++++++++++-- .../speko_asr_python/tests/test_reporting.py | 189 +++++++++++++++--- 5 files changed, 430 insertions(+), 78 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md index 51c8aeee5d..e876662172 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -29,20 +29,26 @@ timestamps still line up with the caller's audio. The buffer holds one session at a time. A frame whose metadata `session_id` differs from the held frames' drops them, and a finalize while disconnected -drops the finished turn's held frames, both recorded as dropped audio: the -replay after reconnecting never merges one session's speech into another's. +drops the finished turn's held frames, both recorded as dropped audio. Frames +still queued when the session becomes ready follow the same rule: the replay +after reconnecting never merges one session's speech into another's. The transport is raw signed 16-bit little-endian PCM. A TEN `asr_finalize` message sends Speko `input.commit` when any audio was sent since the last commit. Each finalize is serialized and acknowledged with its own `finalize_id` -and metadata, including disconnected and timeout paths. The Router answers -every commit with exactly one `transcript.final`, in order (empty when the -provider had nothing left to finalize), so finals pair with commits first in, -first out; a final with no commit outstanding is the provider's own endpoint. -If no final arrives within `finalize_timeout_sec`, the extension logs a -warning, keeps the session, and still completes the finalize. That commit -stays outstanding: its late final answers it, is reported with the timed-out -turn's metadata, and never completes the next finalize. +and metadata, including disconnected and timeout paths. Each commit carries a +fresh `commit_id`, and the Router answers it with exactly one +`transcript.final` stamped with that `commit_id` (empty when the provider had +nothing left to finalize). Only that final completes the finalize; a final +without a `commit_id` is the provider's own endpoint and is emitted as a +normal result. If no answer arrives within `finalize_timeout_sec`, the +extension logs a warning, keeps the session, and still completes the +finalize. A late answer is reported with the timed-out turn's metadata and +never completes another finalize. +A Router that does not stamp answers (no `commit_ids` in `session.ready`, and +the first answer has no `commit_id`) answers commits in order; finals are then +paired first in, first out, and a commit timeout resets the session so later +answers cannot be paired one behind. Empty vendor transcripts are not emitted as user turns. TTLW comes from the first non-empty final after `asr_finalize`; when the commit is acknowledged without one (already-finalized input or an empty final), TTLW is the latency to diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py index cc8c44ddca..ba3fbeeaeb 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py @@ -1,7 +1,6 @@ import asyncio import json import uuid -from collections import deque from collections.abc import Awaitable, Callable from typing import Any from urllib.parse import urlparse, urlunparse @@ -55,7 +54,7 @@ class SpekoCommitTimeout(SpekoRouterError): class _PendingCommit: """An input.commit sent and not yet answered by a transcript.final.""" - def __init__(self, context: Any) -> None: + def __init__(self, commit_id: str, context: Any) -> None: self.answered: asyncio.Future[None] = ( asyncio.get_running_loop().create_future() ) @@ -63,6 +62,7 @@ def __init__(self, context: Any) -> None: self.answered.add_done_callback( lambda future: future.cancelled() or future.exception() ) + self.commit_id = commit_id self.context = context self.abandoned = False @@ -99,10 +99,14 @@ def __init__( self.usage: dict[str, Any] = {} self._ws: ClientConnection | None = None self._listener_task: asyncio.Task[None] | None = None - # The Router answers every input.commit with exactly one - # transcript.final, in order, so finals pair with commits FIFO. A - # final with no commit outstanding is the provider's own endpoint. - self._pending_commits: deque[_PendingCommit] = deque() + # Sent commits by commit_id, oldest first. The Router answers each + # input.commit with exactly one transcript.final. + self._pending_commits: dict[str, _PendingCommit] = {} + # True: the Router stamps each answer with its commit's commit_id, so + # only that final answers it and a final without one is the provider's + # own. False: an older Router answers commits in order. None: this + # session has not shown which yet. + self._correlated: bool | None = None self._audio_since_commit = False self._ready = False self._closing = False @@ -144,6 +148,7 @@ async def connect(self) -> None: self.request_id = str(event.get("request_id", "")) self.route = dict(event.get("route", {})) self._pending_commits.clear() + self._correlated = True if event.get("commit_ids") is True else None self._audio_since_commit = False self._ready = True await self.on_event(event) @@ -189,6 +194,10 @@ async def commit(self, context: Any = None) -> None: Returns when the Router answers this commit. A provider final that arrives first does not show where its audio ends, so any audio sent since the last commit is committed and its own answer awaited. + + Raises SpekoCommitTimeout when no answer came but the session is still + usable. Without commit ids a missing answer would shift every later + pairing, so the timeout is a plain SpekoRouterError: reset the session. """ if not self.is_ready or self._ws is None: raise SpekoRouterError( @@ -196,13 +205,18 @@ async def commit(self, context: Any = None) -> None: ) if not self._audio_since_commit: return - pending = _PendingCommit(context) - self._pending_commits.append(pending) + pending = _PendingCommit(uuid.uuid4().hex, context) + self._pending_commits[pending.commit_id] = pending try: - await self._ws.send('{"type":"input.commit"}') + await self._ws.send( + json.dumps( + {"type": "input.commit", "commit_id": pending.commit_id}, + separators=(",", ":"), + ) + ) except BaseException: # Never sent, so the Router owes it no answer. - self._pending_commits.remove(pending) + del self._pending_commits[pending.commit_id] raise self._audio_since_commit = False try: @@ -213,9 +227,16 @@ async def commit(self, context: Any = None) -> None: except asyncio.TimeoutError as error: # Stays outstanding: its late final must answer it, not the next. pending.abandoned = True - raise SpekoCommitTimeout( + if self._correlated: + raise SpekoCommitTimeout( + "request_timeout", + "Timed out waiting for a final Speko transcript", + retryable=True, + ) from error + raise SpekoRouterError( "request_timeout", - "Timed out waiting for a final Speko transcript", + "Timed out waiting for a final Speko transcript; in-order " + "answers can no longer be paired", retryable=True, ) from error except asyncio.CancelledError: @@ -266,9 +287,9 @@ async def _listen(self) -> None: break pending = None - if event_type == "transcript.final" and self._pending_commits: - pending = self._pending_commits.popleft() - if pending.abandoned: + if event_type == "transcript.final": + pending = self._answered_commit(event) + if pending is not None and pending.abandoned: event = {**event, LATE_COMMIT_CONTEXT: pending.context} # Deliver before answering: the finalize must not complete # ahead of the final that answers it. @@ -281,8 +302,9 @@ async def _listen(self) -> None: error = SpekoRouterError("relay_error", str(caught), retryable=True) finally: self._ready = False - while self._pending_commits: - pending = self._pending_commits.popleft() + pending_commits = list(self._pending_commits.values()) + self._pending_commits.clear() + for pending in pending_commits: if not pending.answered.done(): pending.answered.set_exception( error @@ -295,6 +317,19 @@ async def _listen(self) -> None: if not self._closing or error is not None: await self.on_disconnect(error) + def _answered_commit(self, event: dict[str, Any]) -> _PendingCommit | None: + """The commit this final answers, or None for the provider's own.""" + commit_id = event.get("commit_id") + if commit_id is not None: + self._correlated = True + # An id that is not pending answers nothing here. + return self._pending_commits.pop(str(commit_id), None) + if self._correlated or not self._pending_commits: + return None + # A commit's answer came without its id: this Router answers in order. + self._correlated = False + return self._pending_commits.pop(next(iter(self._pending_commits))) + async def _close_socket(self) -> None: self._ready = False task = self._listener_task diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py index e4be8cb8b1..f5d4aeb68d 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -395,12 +395,17 @@ async def _consume_audio_frame( await super()._handle_audio_frame(ten_env, frame) def _drop_buffered_frames(self) -> None: + dropped = [] while not self.buffered_frames.empty(): - frame = self.buffered_frames.get_nowait() - self._record_dropped_audio(len(frame.get_buf())) + dropped.append(self.buffered_frames.get_nowait()) + self._record_dropped_frames(dropped) self.buffered_frames_size = 0 self._buffered_session = None + def _record_dropped_frames(self, frames: list[AudioFrame]) -> None: + for frame in frames: + self._record_dropped_audio(len(frame.get_buf())) + @staticmethod def _frame_session_id(frame: AudioFrame) -> str | None: raw, _ = frame.get_property_to_json("metadata") @@ -547,9 +552,10 @@ async def finalize(self, session_id: str | None) -> None: await self._commit_task acknowledged = True except SpekoCommitTimeout as timeout: - # The provider may have finalized this turn on its own, so - # the Router has no final left to send. The session is - # still usable: complete the finalize instead of failing. + # Answers carry their commit_id, so a missing one shifts no + # later pairing. The session is still usable: complete the + # finalize instead of failing. (Without commit ids the + # client raises a plain error, and the session is reset.) self.ten_env.log_warn( "Speko ASR commit was not acknowledged: " f"{safe_error(timeout)}", @@ -662,15 +668,27 @@ async def _on_router_event(self, event: dict[str, Any]) -> None: # Requeue in order through the base consumer, which reads metadata # per frame. No new ingress is needed to release buffered audio. if not self.buffered_frames.empty(): - pending = [] + replay = [] while not self.buffered_frames.empty(): - pending.append(self.buffered_frames.get_nowait()) + replay.append(self.buffered_frames.get_nowait()) + session = self._buffered_session + # Frames still queued reach the consumer only once it is + # connected, past its session check. Hold them to the same + # boundary: one stream must not carry two sessions' audio. while not self.audio_frames_queue.empty(): - pending.append(self.audio_frames_queue.get_nowait()) + frame = self.audio_frames_queue.get_nowait() + frame_session = self._frame_session_id(frame) + if frame_session is None: + frame_session = session + if replay and frame_session != session: + self._record_dropped_frames(replay) + replay = [] + session = frame_session + replay.append(frame) self.buffered_frames_size = 0 self._buffered_session = None self._audio_queue_drained.clear() - for frame in pending: + for frame in replay: self.audio_frames_queue.put_nowait(frame) await self.on_connected() if self.stopped or epoch != self._connection_epoch: diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py index aaacee48a1..1d5c64e04b 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py @@ -1,5 +1,6 @@ import asyncio import json +import re from unittest.mock import AsyncMock, patch import pytest @@ -38,6 +39,29 @@ async def close(self): self.closed = True +def commit_ids(websocket): + """commit_id of every input.commit sent, in order.""" + ids = [] + for value in websocket.sent: + if isinstance(value, str): + message = json.loads(value) + if message.get("type") == "input.commit": + ids.append(message["commit_id"]) + return ids + + +async def until(predicate): + for _ in range(50): + if predicate(): + return + await asyncio.sleep(0) + raise AssertionError("condition not reached") + + +# A Router that stamps each commit's answer with its commit_id. +CORRELATED_READY = json.dumps({"type": "session.ready", "commit_ids": True}) + + def make_client(events, disconnects): return SpekoASRClient( api_key="test-key", @@ -100,7 +124,9 @@ async def test_streams_audio_and_waits_for_final_transcript(): assert headers["Idempotency-Key"] assert json.loads(websocket.sent[0])["type"] == "session.configure" assert websocket.sent[1] == b"\x00\x01" - assert json.loads(websocket.sent[2]) == {"type": "input.commit"} + commit = json.loads(websocket.sent[2]) + assert commit["type"] == "input.commit" + assert re.fullmatch(r"[A-Za-z0-9._:-]{1,64}", commit["commit_id"]) assert [event["type"] for event in events] == [ "session.ready", "transcript.delta", @@ -164,8 +190,8 @@ def test_websocket_url_preserves_base_path(): @pytest.mark.asyncio -async def test_late_final_answers_its_timed_out_commit_not_the_next(): - websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) +async def test_late_answer_keeps_its_timed_out_commit_and_resolves_no_other(): + websocket = FakeWebSocket(CORRELATED_READY) events = [] client = make_client(events, []) client.finalize_timeout_sec = 0.01 @@ -177,27 +203,159 @@ async def test_late_final_answers_its_timed_out_commit_not_the_next(): await client.send_audio(b"\x00\x00") with pytest.raises(SpekoCommitTimeout, match="Timed out"): await client.commit({"turn": 1}) - # A missing acknowledgement is not a transport failure. + # Answers carry their commit_id, so the session stays usable. assert client.is_ready client.finalize_timeout_sec = 1 await client.send_audio(b"\x01\x01") second = asyncio.create_task(client.commit({"turn": 2})) - await asyncio.sleep(0) + await until(lambda: len(commit_ids(websocket)) == 2) + first_id, second_id = commit_ids(websocket) + assert first_id != second_id websocket.messages.put_nowait( - json.dumps({"type": "transcript.final", "text": "late"}) + json.dumps( + { + "type": "transcript.final", + "text": "late", + "commit_id": first_id, + } + ) ) - for _ in range(20): - if events[-1].get("text") == "late": - break - await asyncio.sleep(0) + await until(lambda: events[-1].get("text") == "late") assert events[-1][LATE_COMMIT_CONTEXT] == {"turn": 1} assert not second.done() websocket.messages.put_nowait( - json.dumps({"type": "transcript.final", "text": "second"}) + json.dumps( + { + "type": "transcript.final", + "text": "own", + "commit_id": second_id, + } + ) ) await asyncio.wait_for(second, 1) assert LATE_COMMIT_CONTEXT not in events[-1] - assert websocket.sent.count('{"type":"input.commit"}') == 2 + await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_permanently_missing_answer_does_not_shift_the_next_turn(): + websocket = FakeWebSocket(CORRELATED_READY) + events = [] + client = make_client(events, []) + client.finalize_timeout_sec = 0.01 + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + await client.connect() + await client.send_audio(b"\x00\x00") + with pytest.raises(SpekoCommitTimeout): + await client.commit({"turn": 1}) + client.finalize_timeout_sec = 1 + await client.send_audio(b"\x01\x01") + second = asyncio.create_task(client.commit({"turn": 2})) + await until(lambda: len(commit_ids(websocket)) == 2) + # The first answer never arrives; the second turn's own answer does. + websocket.messages.put_nowait( + json.dumps( + { + "type": "transcript.final", + "text": "two", + "commit_id": commit_ids(websocket)[1], + } + ) + ) + await asyncio.wait_for(second, 1) + assert events[-1]["text"] == "two" + assert LATE_COMMIT_CONTEXT not in events[-1] + assert client.is_ready + await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_provider_final_in_flight_when_commit_is_sent_is_not_its_answer(): + websocket = FakeWebSocket(CORRELATED_READY) + events = [] + client = make_client(events, []) + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + await client.connect() + await client.send_audio(b"speech") + commit = asyncio.create_task(client.commit()) + await until(lambda: commit_ids(websocket)) + # The provider's own endpoint was already on its way: no commit_id. + websocket.messages.put_nowait( + json.dumps({"type": "transcript.final", "text": "auto"}) + ) + await until(lambda: events[-1].get("text") == "auto") + assert not commit.done() + websocket.messages.put_nowait( + json.dumps( + { + "type": "transcript.final", + "text": "", + "commit_id": commit_ids(websocket)[0], + } + ) + ) + await asyncio.wait_for(commit, 1) + assert [event.get("text") for event in events[1:]] == ["auto", ""] + await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_router_echoing_commit_ids_is_detected_from_the_first_answer(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + events = [] + client = make_client(events, []) + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + await client.connect() + for turn in range(2): + await client.send_audio(b"speech") + commit = asyncio.create_task(client.commit()) + await until(lambda: len(commit_ids(websocket)) == turn + 1) + if turn: + # Correlated now: a final without an id is not an answer. + websocket.messages.put_nowait( + json.dumps({"type": "transcript.final", "text": "auto"}) + ) + await until(lambda: events[-1].get("text") == "auto") + assert not commit.done() + websocket.messages.put_nowait( + json.dumps( + { + "type": "transcript.final", + "text": f"turn {turn}", + "commit_id": commit_ids(websocket)[turn], + } + ) + ) + await asyncio.wait_for(commit, 1) + await client.close(drain=False) + + +@pytest.mark.asyncio +async def test_without_commit_ids_an_unanswered_commit_requires_a_reset(): + websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) + client = make_client([], []) + client.finalize_timeout_sec = 0.01 + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=websocket), + ): + await client.connect() + await client.send_audio(b"\x00\x00") + # In-order answers would now be one behind: not a SpekoCommitTimeout. + with pytest.raises(SpekoRouterError) as caught: + await client.commit() + assert not isinstance(caught.value, SpekoCommitTimeout) + assert caught.value.code == "request_timeout" + assert caught.value.retryable await client.close(drain=False) @@ -223,16 +381,16 @@ async def test_audio_sent_before_a_provider_final_is_still_committed(): break await asyncio.sleep(0) commit = asyncio.create_task(client.commit()) - await asyncio.sleep(0) - assert websocket.sent[-1] == '{"type":"input.commit"}' + await until(lambda: commit_ids(websocket)) assert not commit.done() + # A Router without commit ids answers in order. websocket.messages.put_nowait( json.dumps({"type": "transcript.final", "text": "next"}) ) await asyncio.wait_for(commit, 1) # Nothing new was sent since that commit, so nothing to finalize. await asyncio.wait_for(client.commit(), 0.1) - assert websocket.sent.count('{"type":"input.commit"}') == 1 + assert len(commit_ids(websocket)) == 1 await client.close(drain=False) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py index d66d952b97..42afeea401 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py @@ -398,8 +398,9 @@ async def connect(*args, **kwargs): class _RouterSocket: """A Router socket that is ready, streams queued events, then closes.""" - def __init__(self, failure=None): + def __init__(self, failure=None, *, commit_ids=False): self.failure = failure + self.commit_ids = commit_ids self.events = asyncio.Queue() self.sent = [] @@ -407,7 +408,19 @@ async def send(self, value): self.sent.append(value) async def recv(self): - return json.dumps({"type": "session.ready"}) + ready = {"type": "session.ready"} + if self.commit_ids: + ready["commit_ids"] = True + return json.dumps(ready) + + def commits(self): + """commit_id of every input.commit sent, in order.""" + return [ + json.loads(value)["commit_id"] + for value in self.sent + if isinstance(value, str) + and json.loads(value).get("type") == "input.commit" + ] def __aiter__(self): return self @@ -460,43 +473,58 @@ async def test_only_an_abnormal_close_reports_an_error(failure): await ext.on_stop(ext.ten_env) +async def _wait_for(predicate): + for _ in range(100): + if predicate(): + return + await asyncio.sleep(0) + raise AssertionError("condition not reached") + + +async def _stream(ext, session): + await ext.on_audio_frame(ext.ten_env, audio({"session_id": session})) + await ext._handle_audio_frame( + ext.ten_env, ext.audio_frames_queue.get_nowait() + ) + + +def _results_and_ends(events): + return [ + payload.get("finalize_id") or payload.get("text") + for kind, payload in events + if kind in {"asr_result", "asr_finalize_end"} + ] + + @pytest.mark.asyncio async def test_late_final_keeps_its_turn_and_next_finalize_waits_for_its_own(): ext, events = extension_and_events() ext.client = None ext.config.finalize_timeout_sec = 0.05 - socket = _RouterSocket() - - async def stream(session): - await ext.on_audio_frame(ext.ten_env, audio({"session_id": session})) - await ext._handle_audio_frame( - ext.ten_env, ext.audio_frames_queue.get_nowait() - ) - + socket = _RouterSocket(commit_ids=True) with patch( "speko_asr_python.client.websockets.connect", AsyncMock(return_value=socket), ): await ext.start_connection() - await stream("one") + await _stream(ext, "one") # Turn one's commit times out; its final is still on the way. await ext.on_data(ext.ten_env, finalize_data("one", "one")) ext.client.finalize_timeout_sec = 1 - await stream("two") + await _stream(ext, "two") second = asyncio.create_task( ext.on_data(ext.ten_env, finalize_data("two", "two")) ) - for _ in range(50): - if socket.sent.count('{"type":"input.commit"}') == 2: - break - await asyncio.sleep(0) - socket.events.put_nowait({"type": "transcript.final", "text": "late"}) - for _ in range(50): - if payloads(events, "asr_result"): - break - await asyncio.sleep(0) + await _wait_for(lambda: len(socket.commits()) == 2) + first_id, second_id = socket.commits() + socket.events.put_nowait( + {"type": "transcript.final", "text": "late", "commit_id": first_id} + ) + await _wait_for(lambda: payloads(events, "asr_result")) assert not second.done() - socket.events.put_nowait({"type": "transcript.final", "text": "own"}) + socket.events.put_nowait( + {"type": "transcript.final", "text": "own", "commit_id": second_id} + ) await asyncio.wait_for(second, 1) results = payloads(events, "asr_result") assert [ @@ -505,12 +533,88 @@ async def stream(session): ("late", "one"), ("own", "two"), ] - kinds = [ - payload.get("finalize_id") or payload.get("text") - for kind, payload in events - if kind in {"asr_result", "asr_finalize_end"} + assert _results_and_ends(events) == ["one", "late", "own", "two"] + assert not payloads(events, "error") + await ext.on_stop(ext.ten_env) + + +@pytest.mark.asyncio +async def test_missing_first_answer_then_second_turn_completes_on_its_own(): + ext, events = extension_and_events() + ext.client = None + ext.config.finalize_timeout_sec = 0.05 + socket = _RouterSocket(commit_ids=True) + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(return_value=socket), + ): + await ext.start_connection() + await _stream(ext, "one") + # Turn one's answer never arrives. + await ext.on_data(ext.ten_env, finalize_data("one", "one")) + ext.client.finalize_timeout_sec = 1 + await _stream(ext, "two") + second = asyncio.create_task( + ext.on_data(ext.ten_env, finalize_data("two", "two")) + ) + await _wait_for(lambda: len(socket.commits()) == 2) + socket.events.put_nowait( + { + "type": "transcript.final", + "text": "two", + "commit_id": socket.commits()[1], + } + ) + await asyncio.wait_for(second, 1) + assert [ + (item["text"], item["metadata"]["session_id"]) + for item in payloads(events, "asr_result") + ] == [("two", "two")] + assert _results_and_ends(events) == ["one", "two", "two"] + # The same session served both turns. + assert len(payloads(events, "connection_status_changed")) == 2 + assert not payloads(events, "error") + await ext.on_stop(ext.ten_env) + + +@pytest.mark.asyncio +async def test_without_commit_ids_a_missing_answer_resets_and_next_turn_completes(): + ext, events = extension_and_events() + ext.client = None + ext.config.finalize_timeout_sec = 0.05 + ext.RECONNECT_DELAYS = (0.0,) + first, second = _RouterSocket(), _RouterSocket() + with patch( + "speko_asr_python.client.websockets.connect", + AsyncMock(side_effect=[first, second]), + ): + await ext.start_connection() + await _stream(ext, "one") + # An older Router answers in order; this answer never arrives. + await ext.on_data(ext.ten_env, finalize_data("one", "one")) + await _wait_for(ext.is_connected) + ext.client.finalize_timeout_sec = 1 + await _stream(ext, "two") + turn = asyncio.create_task( + ext.on_data(ext.ten_env, finalize_data("two", "two")) + ) + await _wait_for(second.commits) + second.events.put_nowait({"type": "transcript.final", "text": "two"}) + await asyncio.wait_for(turn, 1) + assert len(first.commits()) == 1 + assert [ + (item["text"], item["metadata"]["session_id"]) + for item in payloads(events, "asr_result") + ] == [("two", "two")] + assert _results_and_ends(events) == ["one", "two", "two"] + # One non-fatal error for the reset, then a fresh session. + assert [error["code"] for error in payloads(events, "error")] == [ + int(ModuleErrorCode.NON_FATAL_ERROR.value) ] - assert kinds == ["one", "late", "own", "two"] + assert [ + status["current"] + for status in payloads(events, "connection_status_changed") + ] == ["connecting", "connected", "disconnected", "connecting", "connected"] await ext.on_stop(ext.ten_env) @@ -787,6 +891,37 @@ async def test_disconnected_buffer_replays_only_the_latest_session(tmp_path): assert (tmp_path / "audio.pcm").read_bytes() == bytes(64000) +@pytest.mark.asyncio +async def test_replay_holds_queued_audio_to_the_session_boundary(): + ext, events = extension_and_events() + client = ext.client + client.is_ready = False + ext.auto_connect = False + await ext.on_audio_frame(ext.ten_env, audio({"session_id": "old"})) + await ext._handle_audio_frame( + ext.ten_env, ext.audio_frames_queue.get_nowait() + ) + assert ext.buffered_frames.qsize() == 1 + # The new session's frame is still queued when the session becomes ready. + await ext.on_audio_frame(ext.ten_env, audio({"session_id": "new"})) + client.is_ready = True + ext.on_connected = AsyncMock() + await ext._on_router_event({"type": "session.ready"}) + assert ext.audio_frames_queue.qsize() == 1 + assert ext._dropped_audio_bytes == 32000 + await ext._handle_audio_frame( + ext.ten_env, ext.audio_frames_queue.get_nowait() + ) + client.send_audio.assert_awaited_once() + # The transcript arrives only after both frames were consumed. + await ext._on_router_event({"type": "transcript.final", "text": "hello"}) + assert [ + item["metadata"]["session_id"] + for item in payloads(events, "asr_result") + ] == ["new"] + await ext.stop_connection() + + @pytest.mark.asyncio async def test_disconnected_finalize_drops_the_finished_turns_held_audio(): ext, events = extension_and_events() From 74ab5b59d732438c67e8c2119823821b9b286b76 Mon Sep 17 00:00:00 2001 From: Imakirov Date: Fri, 13 Nov 2026 07:11:26 +0500 Subject: [PATCH 17/17] fix(speko_asr): take commit correlation only from session.ready The Router advertises "commit_ids": true in session.ready when it stamps every commit's answer with its commit_id. That flag alone now selects correlated mode; without it, answers pair in order and a commit timeout resets the session. The guessing from the first answer is removed, so an auto-final in flight at a session's first commit can no longer flip the session to in-order pairing. A test covers one provider final answering two pending commits: an empty answer for the older id, then the words for the newest. Co-Authored-By: Claude Opus 5.5 --- .../extension/speko_asr_python/README.md | 23 ++++++----- .../extension/speko_asr_python/client.py | 28 +++++++------- .../speko_asr_python/tests/test_client.py | 38 +++++++++++-------- 3 files changed, 47 insertions(+), 42 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md index e876662172..0569b8d755 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -37,18 +37,17 @@ The transport is raw signed 16-bit little-endian PCM. A TEN `asr_finalize` message sends Speko `input.commit` when any audio was sent since the last commit. Each finalize is serialized and acknowledged with its own `finalize_id` and metadata, including disconnected and timeout paths. Each commit carries a -fresh `commit_id`, and the Router answers it with exactly one -`transcript.final` stamped with that `commit_id` (empty when the provider had -nothing left to finalize). Only that final completes the finalize; a final -without a `commit_id` is the provider's own endpoint and is emitted as a -normal result. If no answer arrives within `finalize_timeout_sec`, the -extension logs a warning, keeps the session, and still completes the -finalize. A late answer is reported with the timed-out turn's metadata and -never completes another finalize. -A Router that does not stamp answers (no `commit_ids` in `session.ready`, and -the first answer has no `commit_id`) answers commits in order; finals are then -paired first in, first out, and a commit timeout resets the session so later -answers cannot be paired one behind. +fresh `commit_id`. When `session.ready` has `"commit_ids": true`, the Router +answers each commit with exactly one `transcript.final` stamped with that +`commit_id` (empty when the provider had nothing left to finalize). Only that +final completes the finalize; a final without a `commit_id` is the provider's +own endpoint and is emitted as a normal result. If no answer arrives within +`finalize_timeout_sec`, the extension logs a warning, keeps the session, and +still completes the finalize. A late answer is reported with the timed-out +turn's metadata and never completes another finalize. +Without that flag the Router answers commits in order: finals are paired first +in, first out, and a commit timeout resets the session so later answers cannot +be paired one behind. Empty vendor transcripts are not emitted as user turns. TTLW comes from the first non-empty final after `asr_finalize`; when the commit is acknowledged without one (already-finalized input or an empty final), TTLW is the latency to diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py index ba3fbeeaeb..f73b7260e3 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py @@ -102,11 +102,11 @@ def __init__( # Sent commits by commit_id, oldest first. The Router answers each # input.commit with exactly one transcript.final. self._pending_commits: dict[str, _PendingCommit] = {} - # True: the Router stamps each answer with its commit's commit_id, so - # only that final answers it and a final without one is the provider's - # own. False: an older Router answers commits in order. None: this - # session has not shown which yet. - self._correlated: bool | None = None + # Set from session.ready "commit_ids": the Router stamps each answer + # with its commit's commit_id, so only that final answers it and a + # final without one is the provider's own. Without it the Router + # answers commits in order. + self._correlated = False self._audio_since_commit = False self._ready = False self._closing = False @@ -148,7 +148,7 @@ async def connect(self) -> None: self.request_id = str(event.get("request_id", "")) self.route = dict(event.get("route", {})) self._pending_commits.clear() - self._correlated = True if event.get("commit_ids") is True else None + self._correlated = event.get("commit_ids") is True self._audio_since_commit = False self._ready = True await self.on_event(event) @@ -196,8 +196,9 @@ async def commit(self, context: Any = None) -> None: since the last commit is committed and its own answer awaited. Raises SpekoCommitTimeout when no answer came but the session is still - usable. Without commit ids a missing answer would shift every later - pairing, so the timeout is a plain SpekoRouterError: reset the session. + usable. A Router without commit ids answers in order, so a missing + answer would shift every later pairing: there the timeout is a plain + SpekoRouterError, and the session must be reset. """ if not self.is_ready or self._ws is None: raise SpekoRouterError( @@ -319,15 +320,14 @@ async def _listen(self) -> None: def _answered_commit(self, event: dict[str, Any]) -> _PendingCommit | None: """The commit this final answers, or None for the provider's own.""" - commit_id = event.get("commit_id") - if commit_id is not None: - self._correlated = True + if self._correlated: + commit_id = event.get("commit_id") + if commit_id is None: + return None # An id that is not pending answers nothing here. return self._pending_commits.pop(str(commit_id), None) - if self._correlated or not self._pending_commits: + if not self._pending_commits: return None - # A commit's answer came without its id: this Router answers in order. - self._correlated = False return self._pending_commits.pop(next(iter(self._pending_commits))) async def _close_socket(self) -> None: diff --git a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py index 1d5c64e04b..146f6a75fa 100644 --- a/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py @@ -306,36 +306,42 @@ async def test_provider_final_in_flight_when_commit_is_sent_is_not_its_answer(): @pytest.mark.asyncio -async def test_router_echoing_commit_ids_is_detected_from_the_first_answer(): - websocket = FakeWebSocket(json.dumps({"type": "session.ready"})) +async def test_one_provider_final_answers_each_pending_commit_by_its_id(): + websocket = FakeWebSocket(CORRELATED_READY) events = [] client = make_client(events, []) + client.finalize_timeout_sec = 0.01 with patch( "speko_asr_python.client.websockets.connect", AsyncMock(return_value=websocket), ): await client.connect() - for turn in range(2): - await client.send_audio(b"speech") - commit = asyncio.create_task(client.commit()) - await until(lambda: len(commit_ids(websocket)) == turn + 1) - if turn: - # Correlated now: a final without an id is not an answer. - websocket.messages.put_nowait( - json.dumps({"type": "transcript.final", "text": "auto"}) - ) - await until(lambda: events[-1].get("text") == "auto") - assert not commit.done() + await client.send_audio(b"one") + with pytest.raises(SpekoCommitTimeout): + await client.commit({"turn": 1}) + client.finalize_timeout_sec = 1 + await client.send_audio(b"two") + second = asyncio.create_task(client.commit({"turn": 2})) + await until(lambda: len(commit_ids(websocket)) == 2) + first_id, second_id = commit_ids(websocket) + # The Router answers the older commit with an empty final first, then + # gives the provider's words to the newest commit. + for text, commit_id in (("", first_id), ("one two", second_id)): websocket.messages.put_nowait( json.dumps( { "type": "transcript.final", - "text": f"turn {turn}", - "commit_id": commit_ids(websocket)[turn], + "text": text, + "commit_id": commit_id, } ) ) - await asyncio.wait_for(commit, 1) + await asyncio.wait_for(second, 1) + assert [ + (event["text"], event.get(LATE_COMMIT_CONTEXT)) + for event in events[1:] + ] == [("", {"turn": 1}), ("one two", None)] + assert not client._pending_commits await client.close(drain=False)