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..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 @@ -48,6 +48,7 @@ "smallest_asr_python", "rtzr_asr_python", "soniox_asr_python", + "speko_asr_python", "tencent_asr_python", } ) 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/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/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 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..0569b8d755 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/README.md @@ -0,0 +1,134 @@ +# 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` | 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`–`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 +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 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. 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. Each commit carries a +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 +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 +{ + "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"}`. + + +## 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 have one background recovery loop with delays of 0.5, 1, +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 +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. + +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/__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..f73b7260e3 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/client.py @@ -0,0 +1,369 @@ +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", "")), + ) + + +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, commit_id: str, 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.commit_id = commit_id + 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) + 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 + # Sent commits by commit_id, oldest first. The Router answers each + # input.commit with exactly one transcript.final. + self._pending_commits: dict[str, _PendingCommit] = {} + # 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 + + @property + def is_ready(self) -> bool: + return self._ready and not self._closing 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, + close_timeout=2.0, + ) + 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._pending_commits.clear() + self._correlated = event.get("commit_ids") is True + self._audio_since_commit = False + 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 = { + 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, asyncio.CancelledError): + 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) + if audio: + self._audio_since_commit = True + + 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. + + Raises SpekoCommitTimeout when no answer came but the session is still + 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( + "relay_error", "Speko ASR session is not ready", retryable=True + ) + if not self._audio_since_commit: + return + pending = _PendingCommit(uuid.uuid4().hex, context) + self._pending_commits[pending.commit_id] = pending + try: + 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. + del self._pending_commits[pending.commit_id] + raise + self._audio_since_commit = False + try: + await asyncio.wait_for( + 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 + 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; in-order " + "answers can no longer be paired", + retryable=True, + ) from error + except asyncio.CancelledError: + pending.abandoned = True + raise + + 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 + 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 + 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", {})) + await self.on_event(event) + break + + pending = None + 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. + await self.on_event(event) + 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 + 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 + 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) + + def _answered_commit(self, event: dict[str, Any]) -> _PendingCommit | None: + """The commit this final answers, or None for the provider's own.""" + 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 not self._pending_commits: + return None + return self._pending_commits.pop(next(iter(self._pending_commits))) + + 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) + websocket, self._ws = self._ws, None + if websocket is not None: + try: + await websocket.close() + except Exception: + pass + + @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..bae8fa774b --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/config.py @@ -0,0 +1,178 @@ +import copy +import re +from urllib.parse import urlsplit, urlunsplit +from pathlib import Path +from typing import Any + +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) + + 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 = 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 + 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 + + @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 ( + "api_key", + "base_url", + "sample_rate", + "channels", + "language", + "routing", + "options", + "ready_timeout_sec", + "finalize_timeout_sec", + "buffer_duration_ms", + "max_reconnect_attempts", + ): + 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") + 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: + # 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": + # 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 = 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" + ) + 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: + 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 new file mode 100644 index 0000000000..f5d4aeb68d --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/extension.py @@ -0,0 +1,890 @@ +# +# 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 asyncio +import copy +import json +import time +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 ( + ModuleConnectionStatus, + ModuleError, + ModuleErrorCode, + ModuleErrorVendorInfo, +) +from ten_ai_base.struct import ASRWord +from ten_runtime import AsyncTenEnv, AudioFrame, Data +from typing_extensions import override + +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. +MAX_BUFFER_BYTES = 10 * 1024 * 1024 +FATAL_ROUTER_CODES = frozenset( + { + "authentication_failed", + "insufficient_credit", + "route_not_found", + "capability_unsupported", + "retry_exhausted", + } +) + + +class SpekoASRExtension(AsyncASRBaseExtension): + # 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) + 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._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] = {} + # 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() + 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._ingress_lock = asyncio.Lock() + self._audio_queue_drained = asyncio.Event() + self._audio_queue_drained.set() + self._finalize_context: dict[str, Any] | None = None + + @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] = { + "key": self.config.api_key, + "language": self.config.language, + "base_url": safe_url(self.config.base_url), + "routing": self.config.routing, + } + if self._route: + metadata["route"] = self._route + metadata["model"] = self._route.get("model", "") + return {key: value for key, value in metadata.items() if value} + + @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 ValueError("Failed to read configuration") + 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._init_failed = True + self.config = None + message = safe_error(error) + ten_env.log_error( + f"invalid property: {message}", category=LOG_CATEGORY_KEY_POINT + ) + await self.send_asr_error( + ModuleError( + module="asr", + code=ModuleErrorCode.FATAL_ERROR.value, + message=message, + ) + ) + + @override + async def _ensure_connection(self) -> None: + # 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 + 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._connection_disabled(): + 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() + ) + self._connect_started_at = time.monotonic() + epoch = self._connection_epoch + + 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, 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 + 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 ( + 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) + 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 + self._audio_queue_drained.set() + try: + await super().on_stop(ten_env) + finally: + await self._stop_dumper() + + @override + async def on_deinit(self, ten_env: AsyncTenEnv) -> None: + self.stopped = True + self._audio_queue_drained.set() + 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 + + @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() + # Overflow drops the oldest frames, recorded as dropped audio. + return ASRBufferConfigModeKeep( + 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, + ) + ) + + @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 + # 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())) + # 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._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. + 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() + 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 + 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 _drop_buffered_frames(self) -> None: + dropped = [] + while not self.buffered_frames.empty(): + 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") + 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 + 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: + # 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(): + 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, ModuleErrorCode.FATAL_ERROR + ) + + @override + async def send_audio( + self, frame: AudioFrame, session_id: str | None + ) -> bool: + del session_id + error = None + epoch = self._connection_epoch + buffer = frame.lock_buf() + try: + audio = bytes(buffer) + 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 = {} + # 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: + del session_id + error = None + acknowledged = False + epoch = self._connection_epoch + 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._result_context()) + ) + try: + await self._commit_task + acknowledged = True + except SpekoCommitTimeout as timeout: + # 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)}", + category=LOG_CATEGORY_VENDOR, + ) + 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 Exception as caught: + error = self._router_error(caught) + finally: + if error is not None: + await self._reset_connection(error, epoch) + if not self.stopped: + # 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, 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, self.session_id = previous + + async def _reset_connection( + self, + error: SpekoRouterError, + epoch: int | None = None, + *, + setup: bool = False, + ) -> 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() + # 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, code) + await self.on_disconnected( + 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( + self, error: SpekoRouterError | None + ) -> None: + 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 + event_type = event.get("type") + 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(): + replay = [] + while not self.buffered_frames.empty(): + 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(): + 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 replay: + self.audio_frames_queue.put_nowait(frame) + await self.on_connected() + 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 in {"usage.updated", "session.closed"}: + await self.send_vendor_metrics( + {"usage": dict(event.get("usage", {}))} + ) + + async def _send_transcript( + self, event: dict[str, Any], *, final: bool + ) -> None: + 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: + 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=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.audio_timeline.get_total_user_audio_duration() + async with self._metadata_lock: + if self.stopped or epoch != self._connection_epoch: + return + 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"] = {} + 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, self.session_id = previous + if final: + self._final_cursor_ms = max(self._final_cursor_ms, end_ms) + + 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, + ) + await self.send_asr_error( + ModuleError( + module="asr", + code=code.value, + message=safe_error(error), + vendor_info=self._vendor_info(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] = { + "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 + or self.audio_dumper is not None + ): + 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: + 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 + ) + + @staticmethod + 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 + + @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=safe_error(error) + ) 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..dd5de18923 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/manifest.json @@ -0,0 +1,73 @@ +{ + "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"}, + "max_reconnect_attempts": {"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..9a8e0dcb66 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/property.json @@ -0,0 +1,21 @@ +{ + "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, + "max_reconnect_attempts": 0 + } +} 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..4970bbf164 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/bin/start @@ -0,0 +1,11 @@ +#!/bin/bash + +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 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..8388452693 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +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..88ced285ca --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest>=8.4,<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 new file mode 100644 index 0000000000..146f6a75fa --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_client.py @@ -0,0 +1,574 @@ +import asyncio +import json +import re +from unittest.mock import AsyncMock, patch + +import pytest +from speko_asr_python.client import ( + LATE_COMMIT_CONTEXT, + SpekoASRClient, + SpekoCommitTimeout, + 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 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", + 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( + "speko_asr_python.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" + 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", + "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( + "speko_asr_python.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" + ) + + +@pytest.mark.asyncio +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 + 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, match="Timed out"): + await client.commit({"turn": 1}) + # 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 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", + "commit_id": first_id, + } + ) + ) + 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": "own", + "commit_id": second_id, + } + ) + ) + await asyncio.wait_for(second, 1) + assert LATE_COMMIT_CONTEXT not in events[-1] + 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_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() + 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": text, + "commit_id": commit_id, + } + ) + ) + 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) + + +@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) + + +@pytest.mark.asyncio +async def test_audio_sent_before_a_provider_final_is_still_committed(): + 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"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"}) + ) + for _ in range(20): + if events[-1].get("text") == "first": + break + await asyncio.sleep(0) + commit = asyncio.create_task(client.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 len(commit_ids(websocket)) == 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( + "speko_asr_python.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( + "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() + assert not client._pending_commits + assert client._audio_since_commit + 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( + "speko_asr_python.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( + "speko_asr_python.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 +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) + + +@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 new file mode 100644 index 0000000000..1190255fda --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_extension.py @@ -0,0 +1,365 @@ +import asyncio + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from speko_asr_python.client import SpekoCommitTimeout, 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_commit_timeout_keeps_session_and_completes_finalize_once(): + extension, client = make_extension() + 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() + assert extension.client is None + extension.send_asr_error.assert_awaited_once() + 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, + ): + 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 "api_key" not in extension.vendor_metadata() + 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(_context=None): + 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 + extension._connection_requested = True # dropped after on_start + entered = asyncio.Event() + + 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 + ) + 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_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() + 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_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 + + extension, client = make_extension() + entered, release = asyncio.Event(), asyncio.Event() + + async def close(**_kwargs): + entered.set() + await release.wait() + + client.close.side_effect = close + await 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 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_package_contract.py b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py new file mode 100644 index 0000000000..c1833fc580 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_package_contract.py @@ -0,0 +1,42 @@ +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"]) + + +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..42afeea401 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_asr_python/tests/test_reporting.py @@ -0,0 +1,1230 @@ +"""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 websockets.exceptions import ConnectionClosedError + +from speko_asr_python.client import SpekoCommitTimeout, 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(_context=None): + 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,errors", + [ + (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, errors): + 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"}, + } + ] + 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(_context=None): + 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 ttlw_metrics(events): + return [ + payload + for payload in payloads(events, "metrics") + if "ttlw" in payload["metrics"] + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "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, session): + ext, events = extension_and_events() + await ext._handle_audio_frame(ext.ten_env, audio()) + + async def commit(_context=None): + 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 + # 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 + ] + 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 + + 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_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 + + +class _RouterSocket: + """A Router socket that is ready, streams queued events, then closes.""" + + def __init__(self, failure=None, *, commit_ids=False): + self.failure = failure + self.commit_ids = commit_ids + self.events = asyncio.Queue() + self.sent = [] + + async def send(self, value): + self.sent.append(value) + + async def recv(self): + 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 + + 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) + + +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(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 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(ext, "two") + second = asyncio.create_task( + ext.on_data(ext.ten_env, finalize_data("two", "two")) + ) + 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", "commit_id": second_id} + ) + 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"), + ] + 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 [ + status["current"] + for status in payloads(events, "connection_status_changed") + ] == ["connecting", "connected", "disconnected", "connecting", "connected"] + 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() + 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 +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 +@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": params}), 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) + + +async def _reconnect_with_recorded_delays(ext): + 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() + 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 == 2 + 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(_context=None): + 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_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") + await ext._start_dumper() + client = ext.client + client.is_ready = False + ext.auto_connect = False + for session in ("old", "new"): + 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"}) + 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") + ] == ["new"] + await ext.stop_connection() + 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_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() + 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() + 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.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", + [ + {"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"}}, + {"buffer_duration_ms": 30001}, + {"buffer_duration_ms": -1}, + {"max_reconnect_attempts": -1}, + ], +) +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(_context=None): + 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 + + +@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(_context=None): + 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 _ in range(2): + await ext.on_audio_frame( + ext.ten_env, audio({"session_id": "turn"}) + ) + 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 == ["turn", "turn", "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(_context=None): + 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_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..f2cd9c52db --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/README.md @@ -0,0 +1,57 @@ +# 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. + +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/`: + +```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/__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..061977c9f4 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/client.py @@ -0,0 +1,211 @@ +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", {}) + 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")), + 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) + 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" + ) + 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..bbc7b244f4 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/config.py @@ -0,0 +1,115 @@ +import copy +import re +from urllib.parse import urlsplit, urlunsplit +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, allow_inf_nan=False) + 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") + validate_base_url(self.base_url) + 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": + 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" + ) + 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: + 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 new file mode 100644 index 0000000000..5bc42d725d --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/extension.py @@ -0,0 +1,464 @@ +# +# 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 copy +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, safe_error + + +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: {safe_error(error)}", + category=LOG_CATEGORY_KEY_POINT, + ) + await self._send_error( + ModuleError( + module=ModuleType.LLM, + code=ModuleErrorCode.FATAL_ERROR.value, + message=safe_error(error), + vendor_info=ModuleErrorVendorInfo(vendor="speko"), + ) + ) + + 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: + 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 + ) -> LLMResponseRetrievePrompt: + del ten_env, request + return LLMResponseRetrievePrompt( + prompt=self.config.prompt if self.config else "" + ) + + 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: + raise RuntimeError("Speko LLM extension is not configured") + + started_at = time.monotonic() + response_id = request.request_id + full_content = "" + ttft_ms: int | None = None + stream = request.streaming is not False + idempotency_key = uuid.uuid4().hex + + 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 + ) + 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}): {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 + 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: + 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 { + "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={safe_error(error)}", + category=LOG_CATEGORY_VENDOR, + ) + await self._send_error( + ModuleError( + id=request.request_id, + module=ModuleType.LLM, + code=self._module_error_code(error).value, + message=safe_error(error), + vendor_info=ModuleErrorVendorInfo( + vendor="speko", + code=error.code, + message=safe_error(error), + ), + ) + ) + + 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", + "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/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..4970bbf164 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/bin/start @@ -0,0 +1,11 @@ +#!/bin/bash + +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 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..8388452693 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +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..88ced285ca --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest>=8.4,<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 new file mode 100644 index 0000000000..153e600b8f --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_client.py @@ -0,0 +1,220 @@ +import json + +import httpx +import pytest +from speko_llm2_python.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() + + +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 new file mode 100644 index 0000000000..e3f4038988 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_llm2_python/tests/test_extension.py @@ -0,0 +1,267 @@ +import inspect +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 ten_ai_base.types import LLMToolMetadata, LLMToolMetadataParameter + +from speko_llm2_python.client import SpekoLLMClient, 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 +@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 cancel_all(): + calls.append("cancel") + + 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, + ): + # 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() + + +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", + 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_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..d1032766e5 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/README.md @@ -0,0 +1,91 @@ +# 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` | Language tag for routing/voice | +| `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 + } + } +} +``` + + +## 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; 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 +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/__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..83f2268860 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/client.py @@ -0,0 +1,323 @@ +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 ConnectionClosed, 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", "")), + ) + + +# 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" + 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 + self._cancelled = 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._cancelled = 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, + close_timeout=2.0, + ) + 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: + 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, asyncio.CancelledError): + 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 + ) + + websocket = self._ws + append = json.dumps( + {"type": "input.append", "text": text}, separators=(",", ":") + ) + # 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 + + while not self._cancelled: + try: + raw, received_at = await asyncio.wait_for( + self._timed_recv(websocket), + timeout=self.receive_timeout_sec, + ) + except ConnectionClosed: + if self._cancelled: + return + raise + except asyncio.TimeoutError as error: + raise SpekoRouterError( + "request_timeout", + "Timed out waiting for Speko TTS audio", + retryable=True, + ) from error + + 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", + "Received TTS audio before utterance.started", + retryable=True, + ) + if first_audio: + first_audio = False + yield SpekoTTSEvent( + SpekoTTSEventType.TTFB, + round((received_at - sent_at) * 1000), + ) + if self._cancelled: + return + 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, + ) + + @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: + 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: + 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: + 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) + 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, ConnectionClosed): + # The Router closing first is a completed close. + return + + async def _close_socket(self) -> None: + self._ready = False + websocket, self._ws = self._ws, None + if websocket is not None: + try: + await websocket.close() + except Exception: + pass + + @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..be2c387670 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/config.py @@ -0,0 +1,158 @@ +import copy +import re +from urllib.parse import urlsplit, urlunsplit +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 = 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 + 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") + 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: + # 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": + # 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 = 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" + ) + 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: + 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 new file mode 100644 index 0000000000..3315b7f2a8 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/extension.py @@ -0,0 +1,479 @@ +# +# 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 asyncio +import os +import time +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, safe_error, safe_url + + +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._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 + 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: + 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: {safe_error(error)}", + category=LOG_CATEGORY_KEY_POINT, + ) + await self.send_tts_error( + request_id="", + error=ModuleError( + module=ModuleType.TTS, + code=ModuleErrorCode.FATAL_ERROR.value, + message=safe_error(error), + vendor_info=ModuleErrorVendorInfo(vendor=self.vendor()), + ), + ) + + 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: + # 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: + 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" + + def vendor_metadata(self) -> dict[str, Any]: + if self.config is None: + return {} + metadata: dict[str, Any] = { + "key": self.config.api_key, + "language": self.config.language, + "base_url": safe_url(self.config.base_url), + "routing": self.config.routing, + } + if self._route: + metadata["route"] = self._route + metadata["model"] = self._route.get("model", "") + 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 + + 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 + + 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 + + if self._permanent_error is not None: + raise self._permanent_error + text = text_input.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: + await self._finalize_request(TTSAudioEndReason.REQUEST_END) + except SpekoRouterError as error: + if self._cancelled or self._stopped: + return + await self._handle_router_error(error, text_input.text_input_end) + except Exception as error: + if self._cancelled or self._stopped: + return + await self._handle_router_error( + SpekoRouterError( + "relay_error", safe_error(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._audio_end_sent or self._cancelled: + return + self._finalized = True + 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", safe_error(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: + 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 = {} + self._audio_start_sent = False + 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 + 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_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() + 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() + self.metrics_add_recv_audio_chunks(audio) + # The base emits whole frames and keeps an odd tail for the + # next chunk; count and dump only bytes that left as frames. + combined = self.leftover_bytes + audio + await self.send_tts_audio_data(audio) + 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 + + 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 + cancelled = False + try: + try: + 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 + if isinstance(close_error, SpekoRouterError) + else SpekoRouterError( + "relay_error", safe_error(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 + ) + + def _make_module_error(self, error: SpekoRouterError) -> ModuleError: + 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=safe_error(error), + vendor_info=ModuleErrorVendorInfo( + vendor=self.vendor(), code=error.code, message=safe_error(error) + ), + ) + + 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 final_input and not self._finalized: + await self._finalize_request( + TTSAudioEndReason.ERROR, error=module_error + ) + else: + # 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: + return + await self.send_tts_audio_start( + request_id=self.current_request_id, + 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, *, 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(drain=drain) + finally: + 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 + 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._audio_start_at is None: + return 0 + return int((time.monotonic() - self._audio_start_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", + "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/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..4970bbf164 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/bin/start @@ -0,0 +1,11 @@ +#!/bin/bash + +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 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..8388452693 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +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..88ced285ca --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest>=8.4,<9 +pytest-asyncio>=0.24,<1 +aiofiles>=24 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..1321091964 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_audio_accounting.py @@ -0,0 +1,491 @@ +"""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: + 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] + + 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 == "pcm": + yield SpekoTTSEvent(SpekoTTSEventType.AUDIO, 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", metadata=None): + data = Data.create("tts_text_input") + data.set_property_from_json( + None, + TTSTextInput( + request_id=request_id, + text=text, + text_input_end=end, + 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"})) + 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, **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", **config) + 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.frame_sizes() == [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.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 new file mode 100644 index 0000000000..2e7c66dcca --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_client.py @@ -0,0 +1,370 @@ +import asyncio +import json +from unittest.mock import AsyncMock, patch + +import pytest +from speko_tts2_python.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( + "speko_tts2_python.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( + "speko_tts2_python.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( + "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")] + + 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( + "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: + await client.close() + + 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( + "speko_tts2_python.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( + "speko_tts2_python.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( + "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 + 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( + "speko_tts2_python.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 + + +@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) + + +@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) 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..a72d2a2ec8 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_extension.py @@ -0,0 +1,577 @@ +import json +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", " ", "\n\t", " world "): + await extension.request_tts(text(value)) + # 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 + ) + == 12 + ) + extension.send_tts_error.assert_not_awaited() + 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 "api_key" not in extension.vendor_metadata() + 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("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 + ] == ["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") + 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(**_kwargs): + 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(**_kwargs): + 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() + + +@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(): + 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() + + +@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 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..36c29aa847 --- /dev/null +++ b/ai_agents/agents/ten_packages/extension/speko_tts2_python/tests/test_package_contract.py @@ -0,0 +1,42 @@ +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"]) + + +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/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" }, +] 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` |