From ba7d451b77f1827fc59fd8371b7e6fb429f3481d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:36:19 +0000 Subject: [PATCH 1/3] Send voice_id=aai- to streaming TTS; list voices from GET /v1/voices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TTS gateway is retiring the bare `voice=` query parameter in favour of `voice_id` (preset jane is `aai-jane`, library voices `aai-`). Once that release reaches sandbox000, `assembly speak` / `assembly dub` — and the `live` cascade, the `--show-code` cascade script, and the `agent-cascade` init template, all of which build the same socket URL — would fail with a 3006 invalid_parameter. - `tts/session.py`: `SpeakConfig.query_params` now encodes the voice as `voice_id=aai-` via the new `tts.voices.voice_id` (idempotent, so a copied `aai-jane` works too); the one place the param is built, so speak, dub, and live move together. `voice_name` strips the prefix so `live --voice aai-jane` still validates against the catalog. - `code_gen/agent_cascade.py` and the `agent_cascade` template send `voice_id` as well (the template's knob is renamed `VOICE_ID = "aai-jane"`). - New `tts/voice_list.py` fetches `GET /v1/voices` from the TTS host (raw-key auth like the socket; auth/HTTP/shape failures normalized to CLIErrors) and renders one line per voice. `--list-voices` on `speak`, `dub`, and `live` prints it (`--json` dumps the raw records). The flag is one shared eager option (`options.list_voices_option`) that exits in its callback, so `dub --list-voices` needs no `--lang`; `live --list-voices` therefore now needs `--sandbox` + a key instead of printing the static catalog. - `--tts-config voice_id=…` is reserved like `voice=` (it would be silently overridden by `--voice`). - The static `tts/voices.py` catalog keeps driving language defaults, rotation, validation, and completion. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NoQ5jA9AJQ8wPWZbqbLuNn --- README.md | 1 + aai_cli/AGENTS.md | 2 +- aai_cli/agent_cascade/voices.py | 26 +-- aai_cli/app/tts_voices.py | 19 +++ aai_cli/code_gen/agent_cascade.py | 3 +- aai_cli/commands/agent_cascade/__init__.py | 29 ++-- aai_cli/commands/agent_cascade/_exec.py | 11 +- aai_cli/commands/dub/__init__.py | 9 +- aai_cli/commands/speak/__init__.py | 5 +- .../init/templates/agent_cascade/README.md | 2 +- .../templates/agent_cascade/api/cascade.py | 4 +- .../templates/agent_cascade/api/settings.py | 3 +- aai_cli/options.py | 38 +++++ aai_cli/tts/session.py | 7 +- aai_cli/tts/voice_list.py | 78 +++++++++ aai_cli/tts/voices.py | 22 +++ .../test_snapshots_help_run.ambr | 26 ++- tests/test_agent_cascade_command.py | 29 ++-- tests/test_agent_cascade_show_code.py | 2 +- tests/test_agent_cascade_voices.py | 28 ++-- tests/test_code_gen_agent_cascade.py | 2 +- tests/test_init_template_agent_cascade.py | 6 +- tests/test_init_template_agent_cascade_api.py | 2 +- tests/test_list_voices.py | 86 ++++++++++ tests/test_speak.py | 2 +- tests/test_tts_session.py | 5 +- tests/test_tts_session_dialogue.py | 4 +- tests/test_tts_voice_list.py | 149 ++++++++++++++++++ tests/test_tts_voices.py | 20 +++ 29 files changed, 522 insertions(+), 98 deletions(-) create mode 100644 aai_cli/app/tts_voices.py create mode 100644 aai_cli/tts/voice_list.py create mode 100644 tests/test_list_voices.py create mode 100644 tests/test_tts_voice_list.py diff --git a/README.md b/README.md index a5fd6133..2612d3e7 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ assembly transcribe "https://www.youtube.com/watch?v=awmCtXzFsJo" --speaker-labe ``` `speak` auto-detects `Speaker A:` labels, merges each speaker's turns, and rotates voices. +`assembly --sandbox speak --list-voices` prints the voices the TTS service offers. **Dub a video into another language** — the whole platform in one command: transcription with utterance timestamps, per-utterance LLM translation, TTS for each line (one voice per speaker), and ffmpeg laying the new track over the original video. A great demo is the first YouTube video ever, "Me at the zoo" — it's 19 seconds long, a single clear English speaker, and instantly recognizable, so the dub finishes fast and the before/after is obvious: diff --git a/aai_cli/AGENTS.md b/aai_cli/AGENTS.md index e58e8a18..a9a4b580 100644 --- a/aai_cli/AGENTS.md +++ b/aai_cli/AGENTS.md @@ -154,7 +154,7 @@ heavily-reworked commands with long bodies; small commands keep the inline - **`core/sync_stt.py`** + **`core/signals.py`** + `commands/dictate/` — `assembly dictate`: headless dictation over the **Sync STT API** (`Environment.sync_base`, one POST `/transcribe` per utterance with the required `X-AAI-Model: u3-sync-pro` header; 80 ms–120 s of PCM/WAV). It needs no terminal: recording starts immediately and `dictate_exec._record` polls `signals.stop_on_terminate` between ~100 ms mic chunks for a SIGTERM, which finishes the utterance (clean exit 0) — so a hotkey tool like Hammerspoon can launch it as a background task and `kill -TERM`/`task:terminate()` to transcribe. SIGINT (Ctrl-C) still cancels (exit 130). Both boundaries (the stop latch, mic, HTTP) are injectable, so the suite never needs a real signal or microphone (`tests/test_dictate_exec.py` scripts the SIGTERM latch). Contrast `signals.terminate_as_interrupt` (used by `stream`/`agent`/`speak`), which routes SIGTERM into the *cancel* path instead. - **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). - **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph **assembled** by `brain.py` (tools, backend, middleware, `build_graph`, plus the shared `SpeechDelta`/`ToolNotice`/`ApprovalPause` event types and tool-affordance vocabulary) and **driven** turn-by-turn by `streamer.py` (`build_streamer`, `graph.stream(stream_mode="messages")`) — the natural build-vs-drive seam, split so each file stays under the 500-line gate (the dependency is one-directional: `streamer` imports `brain`, never the reverse). **Context-window management is the graph's job, not the engine's** — `create_deep_agent` wires deepagents' own `SummarizationMiddleware` into the stack (summarize the oldest turns, offload the evicted history to a file), so the engine feeds the *full* untrimmed running history each turn and lets the graph compact it; the old client-side `text.trim_history`/`config.max_history` sliding window is gone from this path (`max_history` now only drives the hand-rolled `--show-code`/`assembly init` cascade, which doesn't use deepagents). The engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. **The `write_todos` plan** (deepagents auto-installs langchain's `TodoListMiddleware`) is reassembled from the streamed call args by `plan.py`'s `TodoCollector` and surfaced as a `plan.TodoUpdate` (not a `ToolNotice`); `engine._consume` forwards it to `renderer.todos_updated` without gating speech, so the voice TUI shows an in-place `messages.TodoList` checklist (and `AgentRenderer` emits a `plan` NDJSON event / a stderr checklist) — a visible plan to go with the spoken "first I'll…, then I'll…". Under `-v` (`debuglog.active()`) `streamer._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`. The write gate is **path-scoped (the allow/interrupt permission model), not all-or-nothing**: `write_gate.write_interrupt_on(config.auto_write_paths)` hands `write_file`/`edit_file` one `InterruptOnConfig` whose `when` predicate fires only for writes *outside* every `--auto-write` subtree, so a write inside one runs ungated while others still pause (empty paths gate every write, the prior behavior). The matching is a transparent posix path-segment prefix check — `--auto-write DIR` is a directory subtree, and reads stay ungated, so no glob/bulk-tool machinery is needed (and no reach into deepagents internals: `create_deep_agent(permissions=…)` is unusable here — it raises `NotImplementedError` against an execute-capable backend like our `SandboxedShellBackend`). A non-string or `..`-containing target fails safe to gating. `execute` stays a plain `True` (unconditionally gated) — a command isn't a single file path to scope. `--auto-write DIR` is normalized to a cwd-rooted virtual root in `_exec._normalize_auto_write` (rejecting `..`/`~`/whole-tree aliases) and requires `--files`. `streamer._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). **Project grounding (independent of `--files`):** `_exec.run_agent_cascade` reads the launch directory's `AGENTS.md`/`CLAUDE.md` via `agent_cascade/project_context.load_project_context()` into `CascadeConfig.project_context`, which `brain.build_graph` threads into `prompt.build_system_prompt(..., project_context=…)` (appended as project background after the persona/tool guidance). `AGENTS.md` wins precedence, identical content (a symlinked `CLAUDE.md`) is de-duplicated, and the total is capped at `project_context.MAX_CONTEXT_CHARS`. It's read at the command boundary (not in `build_graph`) so the brain stays hermetic, and the `--show-code` path builds its own config without it. Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`. The gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool) for delegating a focused subtask is **auto-added by deepagents** — we don't declare it. We only override its prose for a voice turn (a spoken-length summary, not the SDK's "complete answer" default) via a harness profile keyed by the gateway model's provider (`subagents.register_gp_subagent_profile`, called from `build_graph` so the deepagents import stays lazy — and kept off `brain.py`, which sits at the 500-line gate). It inherits the gateway-bound model, the sandboxed toolset, *and* the top-level `interrupt_on` (deepagents' `graph.py` merges the top-level config into the auto-added subagent), so a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` with no per-subagent restatement (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated. -- **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless. +- **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). **The voice rides the socket URL as `voice_id=aai-`** (`voices.voice_id`; the bare `voice=` param is retired server-side and answers 3006 invalid_parameter) — `SpeakConfig.query_params` is the one place that encodes it, so `speak`/`dub`/`live` can't drift; the static `voices.py` catalog only drives language defaults + completion, while `--list-voices` on all three commands is the service's live `GET /v1/voices` (`tts/voice_list.py` fetches, `app/tts_voices.py` emits, and `options.list_voices_option` is the shared flag that does the work in its callback and exits — so `dub --list-voices` needs no `--lang` — and is eager so it wins over an invalid earlier option, like `--help`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless. - **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`). - **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps. - **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`. diff --git a/aai_cli/agent_cascade/voices.py b/aai_cli/agent_cascade/voices.py index 3023b386..ac9f1547 100644 --- a/aai_cli/agent_cascade/voices.py +++ b/aai_cli/agent_cascade/voices.py @@ -3,8 +3,8 @@ The cascade's audio comes from streaming TTS, so its voices are the TTS catalog (`aai_cli.tts.voices`) — not the Voice Agent voices `assembly agent` uses. This module is the thin presentation layer over that catalog: the membership list -that catches a typo'd ``--voice``, the completion callback, and the grouped -``--list-voices`` rendering. +that catches a typo'd ``--voice`` and the completion callback. (``--list-voices`` +itself is the service's live listing — see ``app.tts_voices``.) """ from __future__ import annotations @@ -14,28 +14,10 @@ DEFAULT_VOICE = "jane" -# The selectable voice ids, sorted for a stable --list-voices / completion order. +# The selectable voice names, sorted for a stable completion order. VOICE_NAMES: list[str] = sorted(tts_voices.VOICE_LANGUAGES) -# ISO 639-1 code -> the heading --list-voices groups that language's voices under. -_LANGUAGE_LABELS: dict[str, str] = { - "en": "English", - "fr": "French", - "it": "Italian", - "de": "German", - "es": "Spanish", - "pt": "Portuguese", -} - def complete_voice(incomplete: str) -> list[str]: - """Shell-completion callback for ``--voice``: catalog ids matching the prefix.""" + """Shell-completion callback for ``--voice``: catalog names matching the prefix.""" return choices.complete_prefix(VOICE_NAMES, incomplete) - - -def format_voice_list() -> str: - """Human-readable voice ids for ``--list-voices``, grouped by language.""" - return choices.render_grouped( - (label, [name for name in VOICE_NAMES if tts_voices.VOICE_LANGUAGES[name] == code]) - for code, label in _LANGUAGE_LABELS.items() - ) diff --git a/aai_cli/app/tts_voices.py b/aai_cli/app/tts_voices.py new file mode 100644 index 00000000..8de425e8 --- /dev/null +++ b/aai_cli/app/tts_voices.py @@ -0,0 +1,19 @@ +"""The ``--list-voices`` body shared by the streaming-TTS commands (speak, dub, live). + +The listing is the service's ``GET /v1/voices`` (``tts.voice_list``), so it needs the +sandbox and an API key like the synthesis it informs. The Typer flag that invokes +it is ``options.list_voices_option``. +""" + +from __future__ import annotations + +from aai_cli.app.context import AppState +from aai_cli.tts import session, voice_list +from aai_cli.ui import output + + +def emit_voice_list(command: str, state: AppState, *, json_mode: bool) -> None: + """Print the service's voices: the raw JSON array, or one line per voice.""" + session.require_available(command) + listing = voice_list.fetch_voices(state.resolve_api_key()) + output.emit(listing, voice_list.format_voice_list, json_mode=json_mode) diff --git a/aai_cli/code_gen/agent_cascade.py b/aai_cli/code_gen/agent_cascade.py index 5f5306f0..331b5dcd 100644 --- a/aai_cli/code_gen/agent_cascade.py +++ b/aai_cli/code_gen/agent_cascade.py @@ -6,6 +6,7 @@ from aai_cli.code_gen import agent_cascade_body from aai_cli.core import environments +from aai_cli.tts import voices if TYPE_CHECKING: from aai_cli.agent_cascade.config import CascadeConfig @@ -72,7 +73,7 @@ def _stt_url(speech_model: str, *, format_turns: bool) -> str: def _tts_url(voice: str, language: str | None) -> str: """The streaming-TTS socket URL for the configured voice (sandbox-only host).""" - params: dict[str, str] = {"voice": voice, "sample_rate": "24000"} + params: dict[str, str] = {"voice_id": voices.voice_id(voice), "sample_rate": "24000"} if language is not None: params["language"] = language return f"wss://{environments.active().streaming_tts_host}/v1/ws/?{urlencode(params)}" diff --git a/aai_cli/commands/agent_cascade/__init__.py b/aai_cli/commands/agent_cascade/__init__.py index 172e3968..90933155 100644 --- a/aai_cli/commands/agent_cascade/__init__.py +++ b/aai_cli/commands/agent_cascade/__init__.py @@ -14,11 +14,10 @@ DEFAULT_SYSTEM_PROMPT, ) from aai_cli.agent_cascade.voices import DEFAULT_VOICE -from aai_cli.app.context import AppState, run_command, run_with_options +from aai_cli.app.context import run_with_options from aai_cli.commands.agent_cascade import _exec as agent_cascade_exec from aai_cli.core import choices, llm from aai_cli.streaming.turn_presets import TurnDetectionPreset -from aai_cli.ui import output from aai_cli.ui.help_text import examples_epilog # Option panels that group the per-leg knobs in `--help` instead of one flat wall. @@ -36,11 +35,10 @@ ) -def _emit_voice_list(_state: AppState, json_mode: bool) -> None: - """--list-voices body, routed through run_command so --json yields a machine-readable - array instead of the human list; needs no auth.""" - payload = [{"name": name} for name in voices.VOICE_NAMES] - output.emit(payload, lambda _voices: voices.format_voice_list(), json_mode=json_mode) +def _as_tuple[T](values: list[T] | None) -> tuple[T, ...]: + """A repeatable option's values as the frozen tuple the options dataclass carries + (Typer hands back None, not an empty list, when the flag is never passed).""" + return tuple(values or ()) @app.command( @@ -184,7 +182,8 @@ def live( rich_help_panel=_PANEL_TOOLS, ), device: int | None = typer.Option(None, "--device", help="Microphone device index"), - list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit"), + # Handled eagerly in the option's own callback (it exits), so the body never reads it. + _list_voices: bool = options.list_voices_option("live"), json_out: bool = options.json_option("Emit newline-delimited JSON events"), output_field: choices.TextOrJson | None = typer.Option( None, @@ -224,10 +223,6 @@ def live( the session. """ - if list_voices: - run_command(ctx, _emit_voice_list, json=json_out) - return - opts = agent_cascade_exec.AgentCascadeOptions( source=source, sample=sample, @@ -241,15 +236,15 @@ def live( speech_model=speech_model, format_turns=format_turns, turn_detection=turn_detection, - stt_config=tuple(stt_config or ()), + stt_config=_as_tuple(stt_config), stt_config_file=stt_config_file, max_tokens=max_tokens, - llm_config=tuple(llm_config or ()), + llm_config=_as_tuple(llm_config), language=language, - tts_config=tuple(tts_config or ()), - mcp_config=tuple(mcp_config or ()), + tts_config=_as_tuple(tts_config), + mcp_config=_as_tuple(mcp_config), files=files, - auto_write=tuple(auto_write or ()), + auto_write=_as_tuple(auto_write), show_code=show_code, ) run_with_options(ctx, agent_cascade_exec.run_agent_cascade, opts, json=json_out) diff --git a/aai_cli/commands/agent_cascade/_exec.py b/aai_cli/commands/agent_cascade/_exec.py index 564dc766..46600296 100644 --- a/aai_cli/commands/agent_cascade/_exec.py +++ b/aai_cli/commands/agent_cascade/_exec.py @@ -30,6 +30,7 @@ from aai_cli.streaming.sources import FileSource from aai_cli.streaming.validate import resolve_output_modes from aai_cli.tts import session as tts_session +from aai_cli.tts import voices as tts_voices from aai_cli.ui import output if TYPE_CHECKING: @@ -39,6 +40,7 @@ # message steering the user to the right place instead of silently fighting the cascade. _RESERVED_TTS_KEYS: dict[str, str] = { "voice": "Set the voice with --voice, not --tts-config.", + "voice_id": "Set the voice with --voice, not --tts-config.", "language": "Set the language with --language, not --tts-config.", "sample_rate": "TTS sample rate is fixed to match the live speaker and can't be overridden.", } @@ -48,9 +50,9 @@ class AgentCascadeOptions: """Every `assembly agent-cascade` conversation flag as plain data. - ``--list-voices`` is excluded: it dispatches to its own auth-free body in the - command module. ``--json`` is excluded: run_command resolves it into the - ``json_mode`` argument. + ``--list-voices`` is excluded: it is an eager flag that lists the service's + voices and exits in its own callback (``app.tts_voices``). ``--json`` is + excluded: run_command resolves it into the ``json_mode`` argument. """ source: str | None @@ -334,7 +336,8 @@ def _launch_tui(api_key: str, opts: AgentCascadeOptions, config: CascadeConfig) def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: bool) -> None: """Execute one `assembly agent-cascade` cascade from already-parsed flags.""" text_mode, json_mode = resolve_output_modes(opts.output_field, json_mode=json_mode) - validate_voice(opts.voice, voices.VOICE_NAMES, command="live") + # A copied voice_id (aai-jane, as --list-voices prints them) names the same catalog voice. + validate_voice(tts_voices.voice_name(opts.voice), voices.VOICE_NAMES, command="live") # Streaming TTS has no production host, so the whole cascade is sandbox-only. tts_session.require_available("live") system_prompt_text = _resolve_system_prompt(opts.system_prompt, opts.system_prompt_file) diff --git a/aai_cli/commands/dub/__init__.py b/aai_cli/commands/dub/__init__.py index 0e62fc80..fe4e2a54 100644 --- a/aai_cli/commands/dub/__init__.py +++ b/aai_cli/commands/dub/__init__.py @@ -55,6 +55,7 @@ "Dub a whole folder into German", 'find talks -name "*.mp4" | assembly --sandbox dub --from-stdin -l de', ), + ("See the available voices", "assembly --sandbox dub --list-voices"), ] ), ) @@ -94,9 +95,9 @@ def dub( voice: list[str] = typer.Option( [], "--voice", - help="Voice id for every speaker (e.g. jane, michael, paul), or SPEAKER=VOICE " - "to pin a diarized speaker (repeatable, e.g. --voice A=jane). Default: the " - "target language's native voice(s).", + help="Voice for every speaker (e.g. jane, michael, paul; see --list-voices), or " + "SPEAKER=VOICE to pin a diarized speaker (repeatable, e.g. --voice A=jane). " + "Default: the target language's native voice(s).", ), model: str = typer.Option( llm.DEFAULT_MODEL, @@ -127,6 +128,8 @@ def dub( '"--download-sections" syntax, e.g. "*0:00-15:00" for the first fifteen ' "minutes; repeatable)", ), + # Handled eagerly in the option's own callback (it exits), so the body never reads it. + _list_voices: bool = options.list_voices_option("dub"), json_out: bool = options.json_option("Emit JSON describing the dubbed file"), ) -> None: r"""\[sandbox] Dub a video or audio file into another language diff --git a/aai_cli/commands/speak/__init__.py b/aai_cli/commands/speak/__init__.py index 9df21b4a..4881778a 100644 --- a/aai_cli/commands/speak/__init__.py +++ b/aai_cli/commands/speak/__init__.py @@ -45,6 +45,7 @@ "Save to a WAV instead of playing", 'assembly --sandbox speak "Hello" --out /tmp/hello.wav', ), + ("See the available voices", "assembly --sandbox speak --list-voices"), ] ), ) @@ -60,7 +61,7 @@ def speak( voice: list[str] = typer.Option( [], "--voice", - help="Voice id (e.g. jane, michael, mary, paul, eve, george), or SPEAKER=VOICE " + help="Voice (e.g. jane, michael, mary; see --list-voices), or SPEAKER=VOICE " "for diarized input (repeatable, e.g. --voice A=jane)", ), language: str = typer.Option( @@ -78,6 +79,8 @@ def speak( out: Path | None = typer.Option( None, "--out", help="Write a WAV file instead of playing through the speakers" ), + # Handled eagerly in the option's own callback (it exits), so the body never reads it. + _list_voices: bool = options.list_voices_option("speak"), json_out: bool = options.json_option("Emit JSON metadata about the synthesized audio"), ) -> None: r"""\[sandbox] Synthesize speech from text with AssemblyAI streaming TTS diff --git a/aai_cli/init/templates/agent_cascade/README.md b/aai_cli/init/templates/agent_cascade/README.md index 93e1ea12..40263bc4 100644 --- a/aai_cli/init/templates/agent_cascade/README.md +++ b/aai_cli/init/templates/agent_cascade/README.md @@ -40,7 +40,7 @@ Set `ASSEMBLYAI_API_KEY` and the three sandbox host vars (`ASSEMBLYAI_STREAMING_ ## Ideas to extend -- Change the `MODEL`, `VOICE`, `SYSTEM_PROMPT`, `GREETING`, or `MAX_HISTORY` in +- Change the `MODEL`, `VOICE_ID`, `SYSTEM_PROMPT`, `GREETING`, or `MAX_HISTORY` in `api/settings.py`. - Replies already stream into TTS sentence-by-sentence as the LLM produces them (`_generate_reply` flushes on each `.`/`!`/`?`), and a sliding window of diff --git a/aai_cli/init/templates/agent_cascade/api/cascade.py b/aai_cli/init/templates/agent_cascade/api/cascade.py index 5f8d2dc1..6c96aac3 100644 --- a/aai_cli/init/templates/agent_cascade/api/cascade.py +++ b/aai_cli/init/templates/agent_cascade/api/cascade.py @@ -36,7 +36,7 @@ class _Settings(Protocol): TTS_HOST: str LLM_GATEWAY_URL: str MODEL: str - VOICE: str + VOICE_ID: str SYSTEM_PROMPT: str GREETING: str MAX_HISTORY: int @@ -83,7 +83,7 @@ def stt_url(settings: _Settings) -> str: def tts_url(settings: _Settings) -> str: """The streaming-TTS WebSocket URL for the configured voice and sample rate.""" - params = urlencode({"voice": settings.VOICE, "sample_rate": settings.OUTPUT_SAMPLE_RATE}) + params = urlencode({"voice_id": settings.VOICE_ID, "sample_rate": settings.OUTPUT_SAMPLE_RATE}) return f"wss://{settings.TTS_HOST}/v1/ws/?{params}" diff --git a/aai_cli/init/templates/agent_cascade/api/settings.py b/aai_cli/init/templates/agent_cascade/api/settings.py index 8cfda0a5..70fb819a 100644 --- a/aai_cli/init/templates/agent_cascade/api/settings.py +++ b/aai_cli/init/templates/agent_cascade/api/settings.py @@ -23,7 +23,8 @@ # The cascade's knobs — edit these to change behavior. MODEL: str = "claude-haiku-4-5-20251001" -VOICE: str = "jane" +# A streaming-TTS voice_id: the preset/library voices are aai-. +VOICE_ID: str = "aai-jane" SYSTEM_PROMPT: str = ( "You are a friendly, concise voice assistant. Keep replies short and conversational. " "Your reply is read aloud by a text-to-speech engine, so write plain spoken prose — " diff --git a/aai_cli/options.py b/aai_cli/options.py index 493e3822..f3806729 100644 --- a/aai_cli/options.py +++ b/aai_cli/options.py @@ -9,6 +9,9 @@ import typer from aai_cli import help_panels +from aai_cli.app import tts_voices +from aai_cli.app.context import run_command +from aai_cli.core import argscan DEFAULT_BATCH_CONCURRENCY = 4 @@ -95,3 +98,38 @@ def batch_force_option( rich_help_panel=help_panels.OPT_BATCH, ) return flag + + +def list_voices_option(command: str) -> bool: + """The ``--list-voices`` flag of the streaming-TTS commands (speak, dub, live). + + One factory so the three can't drift. The flag does its work in its own option + callback and exits, so it needs none of the command's other input (``assembly + --sandbox dub --list-voices`` runs without the otherwise-required ``--lang``); + it is *eager* so that, like ``--help``, it wins even when an option given + earlier on the line is invalid. ``command`` names the command in the sandbox hint. + """ + + def _callback(ctx: typer.Context, *, value: bool) -> bool: + if value: + # The subcommand's own --json isn't parsed yet when an eager option fires, + # so honor it the way the root callback does: off the raw token list. + raw_args: list[str] = ctx.meta.get(argscan.RAW_ARGS_META_KEY, []) + run_command( + ctx, + lambda state, json_mode: tts_voices.emit_voice_list( + command, state, json_mode=json_mode + ), + json=argscan.requests_json(raw_args), + ) + raise typer.Exit() + return value + + flag: bool = typer.Option( + False, + "--list-voices", + help="List the voices the TTS service offers and exit", + is_eager=True, + callback=_callback, + ) + return flag diff --git a/aai_cli/tts/session.py b/aai_cli/tts/session.py index a98005e2..43049aef 100644 --- a/aai_cli/tts/session.py +++ b/aai_cli/tts/session.py @@ -14,7 +14,7 @@ from aai_cli.core import ws as wsutil from aai_cli.core.errors import APIError, CLIError from aai_cli.streaming import diagnostics -from aai_cli.tts import audio, text +from aai_cli.tts import audio, text, voices class _WebSocket(Protocol): @@ -56,6 +56,9 @@ class SpeakConfig: """Per-run TTS parameters. Optional fields are only sent when set, so the server applies its own defaults (voice/language/sample_rate) otherwise. + ``voice`` is the catalog name (``jane``) or a full id (``aai-jane``); it rides + the URL as ``voice_id`` — the service retired the bare ``voice`` parameter + (sending it is a 3006 invalid_parameter once that release lands). ``extra`` carries any further query params verbatim (the escape hatch for fields the named ones don't cover); the named fields always win a key clash.""" @@ -69,7 +72,7 @@ def query_params(self) -> dict[str, str]: # extra first so the named fields below override it on a key clash. params: dict[str, str] = {str(k): str(v) for k, v in self.extra.items()} if self.voice is not None: - params["voice"] = self.voice + params["voice_id"] = voices.voice_id(self.voice) if self.language is not None: params["language"] = self.language if self.sample_rate is not None: diff --git a/aai_cli/tts/voice_list.py b/aai_cli/tts/voice_list.py new file mode 100644 index 00000000..3eea49b8 --- /dev/null +++ b/aai_cli/tts/voice_list.py @@ -0,0 +1,78 @@ +"""The live streaming-TTS voice listing: ``GET /v1/voices`` on the TTS host. + +Backs ``--list-voices`` on `speak`, `dub`, and `live`. The service is the source of +truth for which voices exist (library voices come and go without a CLI release), +so the listing is fetched, not read from the static ``voices`` catalog — which +only drives the language-driven defaults and shell completion. Like +``core/sync_stt.py``, every failure is normalized into the CLIError hierarchy and +the module stays Rich-free. +""" + +from __future__ import annotations + +import httpx2 as httpx + +from aai_cli.core import environments, jsonshape +from aai_cli.core.errors import APIError, auth_failure + +_TIMEOUT = 15.0 +_HTTP_ERROR_MIN_STATUS = 400 +# The per-voice fields worth a column in the human listing, when the service sends them. +_DETAIL_KEYS = ("name", "language", "description") + + +def voices_url() -> str: + """The voice-listing endpoint for the active environment's TTS host.""" + return f"https://{environments.active().streaming_tts_host}/v1/voices" + + +def _raise_for_error(resp: httpx.Response) -> None: + if resp.status_code in (401, 403): + raise auth_failure() + if resp.status_code >= _HTTP_ERROR_MIN_STATUS: + detail = resp.text or f"HTTP {resp.status_code}" + raise APIError(f"Could not list TTS voices ({resp.status_code}): {detail}") + + +def _parse(resp: httpx.Response) -> list[dict[str, object]]: + """The voice objects out of the response: either a bare JSON array or an object + wrapping it under ``voices``. Other shapes are a malformed response.""" + try: + body: object = resp.json() + except ValueError as exc: + raise APIError("The TTS service returned a voice list that is not valid JSON.") from exc + wrapper = jsonshape.as_mapping(body) + listing = jsonshape.as_object_list(wrapper.get("voices") if wrapper is not None else body) + if listing is None: + raise APIError("The TTS service returned a voice list in an unexpected shape.") + return listing + + +def fetch_voices(api_key: str) -> list[dict[str, object]]: + """The service's voices, each a JSON object carrying at least its ``voice_id``.""" + # Streaming TTS authenticates with the raw API key (no Bearer prefix), like its socket. + try: + with httpx.Client(timeout=_TIMEOUT) as client: + resp = client.get(voices_url(), headers={"authorization": api_key}) + except httpx.HTTPError as exc: + raise APIError( + f"Could not reach the TTS service: {exc}", + suggestion="Check your network connection and try again.", + ) from exc + _raise_for_error(resp) + return _parse(resp) + + +def format_voice_list(listing: list[dict[str, object]]) -> str: + """One line per voice: the ``voice_id`` (what ``--voice`` accepts), then any + name/language/description the service sent, so the output stays useful if + the record shape grows.""" + if not listing: + return "No voices available." + ids = [str(voice.get("voice_id", "?")) for voice in listing] + width = max(len(voice_id) for voice_id in ids) + lines: list[str] = [] + for voice_id, voice in zip(ids, listing, strict=True): + details = " · ".join(str(voice[key]) for key in _DETAIL_KEYS if voice.get(key)) + lines.append(f"{voice_id:<{width}} {details}".rstrip()) + return "\n".join(lines) diff --git a/aai_cli/tts/voices.py b/aai_cli/tts/voices.py index b878eed5..5acde09d 100644 --- a/aai_cli/tts/voices.py +++ b/aai_cli/tts/voices.py @@ -4,10 +4,32 @@ voice from the requested language: a non-English language switches to that language's native voice(s) — most ship exactly one, so the language alone selects the voice — while English keeps the curated multi-speaker rotation. + +On the wire a voice is addressed by its ``voice_id`` (``aai-`` for the +preset and library voices; the bare ``voice=`` parameter is retired), so +``voice_id`` maps a catalog name to the id the socket URL carries. The live +listing (`--list-voices`) comes from the service's ``GET /v1/voices`` instead — +see ``voice_list``; this static catalog only drives defaults and completion. """ from __future__ import annotations +# The id prefix the service puts on its own (preset + library) voices. +VOICE_ID_PREFIX = "aai-" + + +def voice_id(voice: str) -> str: + """The ``voice_id`` for a voice: ``jane`` -> ``aai-jane``. A value already + carrying the prefix (as ``--list-voices`` prints them) passes through.""" + return voice if voice.startswith(VOICE_ID_PREFIX) else f"{VOICE_ID_PREFIX}{voice}" + + +def voice_name(voice: str) -> str: + """The catalog name behind a ``--voice`` value: ``aai-jane`` -> ``jane``, so a + copied ``voice_id`` validates against the catalog like the bare name does.""" + return voice.removeprefix(VOICE_ID_PREFIX) + + # Voice id -> ISO 639-1 code of the (single) language the voice speaks. VOICE_LANGUAGES: dict[str, str] = { "alba": "en", diff --git a/tests/__snapshots__/test_snapshots_help_run.ambr b/tests/__snapshots__/test_snapshots_help_run.ambr index 82aa4d1a..101ba2f5 100644 --- a/tests/__snapshots__/test_snapshots_help_run.ambr +++ b/tests/__snapshots__/test_snapshots_help_run.ambr @@ -411,11 +411,12 @@ │ --transcript-id -t TEXT Reuse an existing diarized transcript │ │ of this media instead of transcribing │ │ it again │ - │ --voice TEXT Voice id for every speaker (e.g. jane, │ - │ michael, paul), or SPEAKER=VOICE to │ - │ pin a diarized speaker (repeatable, │ - │ e.g. --voice A=jane). Default: the │ - │ target language's native voice(s). │ + │ --voice TEXT Voice for every speaker (e.g. jane, │ + │ michael, paul; see --list-voices), or │ + │ SPEAKER=VOICE to pin a diarized │ + │ speaker (repeatable, e.g. --voice │ + │ A=jane). Default: the target │ + │ language's native voice(s). │ │ --out PATH Output file (default: │ │ .dub. next to the │ │ input) │ @@ -428,6 +429,8 @@ │ "--download-sections" syntax, e.g. │ │ "*0:00-15:00" for the first fifteen │ │ minutes; repeatable) │ + │ --list-voices List the voices the TTS service offers │ + │ and exit │ │ --json -j Emit JSON describing the dubbed file │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -469,6 +472,8 @@ $ assembly --sandbox dub talk.mp4 -l de --out talk-german.mp4 Dub a whole folder into German $ find talks -name "*.mp4" | assembly --sandbox dub --from-stdin -l de + See the available voices + $ assembly --sandbox dub --list-voices @@ -633,7 +638,8 @@ │ voice agent. What can I help you │ │ with?] │ │ --device INTEGER Microphone device index │ - │ --list-voices Print known voices and exit │ + │ --list-voices List the voices the TTS service │ + │ offers and exit │ │ --json -j Emit newline-delimited JSON │ │ events │ │ --output -o [text|json] Output mode: text (you:/agent: │ @@ -808,8 +814,8 @@ │ main text (boilerplate │ │ stripped). Mutually exclusive │ │ with the text argument │ - │ --voice TEXT Voice id (e.g. jane, michael, │ - │ mary, paul, eve, george), or │ + │ --voice TEXT Voice (e.g. jane, michael, │ + │ mary; see --list-voices), or │ │ SPEAKER=VOICE for diarized │ │ input (repeatable, e.g. --voice │ │ A=jane) │ @@ -823,6 +829,8 @@ │ omitted. │ │ --out PATH Write a WAV file instead of │ │ playing through the speakers │ + │ --list-voices List the voices the TTS service │ + │ offers and exit │ │ --json -j Emit JSON metadata about the │ │ synthesized audio │ │ --help Show this message and exit. │ @@ -841,6 +849,8 @@ $ assembly --sandbox speak --url https://example.com/post Save to a WAV instead of playing $ assembly --sandbox speak "Hello" --out /tmp/hello.wav + See the available voices + $ assembly --sandbox speak --list-voices diff --git a/tests/test_agent_cascade_command.py b/tests/test_agent_cascade_command.py index 8bc5b587..a51f9bb9 100644 --- a/tests/test_agent_cascade_command.py +++ b/tests/test_agent_cascade_command.py @@ -19,6 +19,7 @@ from aai_cli.agent_cascade.config import CascadeConfig from aai_cli.agent_cascade.engine import CascadeDeps from aai_cli.app.context import AppState +from aai_cli.commands import agent_cascade as cascade_command from aai_cli.commands.agent_cascade import _exec from aai_cli.commands.agent_cascade._exec import AgentCascadeOptions, run_agent_cascade from aai_cli.core import config, config_builder @@ -59,24 +60,15 @@ def _opts(**overrides) -> AgentCascadeOptions: return dataclasses.replace(_DEFAULTS, **overrides) -# --- help / list-voices ------------------------------------------------------ - - -def test_list_voices_human_lists_catalog(): - result = runner.invoke(app, ["live", "--list-voices"]) - assert result.exit_code == 0 - assert "jane" in result.output - assert "English:" in result.output - - -def test_list_voices_json_emits_array(): - result = runner.invoke(app, ["live", "--list-voices", "--json"]) - assert result.exit_code == 0 - assert result.output.lstrip().startswith("[") - assert '"jane"' in result.output +def test_as_tuple_freezes_repeatable_values(): + # Typer hands back None for a never-passed repeatable flag: that's an empty tuple, + # while given values are carried over in order. + assert cascade_command._as_tuple(None) == () + assert cascade_command._as_tuple(["a=1", "b=2"]) == ("a=1", "b=2") # --- validation guards ------------------------------------------------------- +# (--list-voices is the shared streaming-TTS flag, covered in test_list_voices.py.) def test_options_are_frozen(): @@ -388,7 +380,12 @@ def test_parse_tts_config_rejects_malformed_pair(): @pytest.mark.parametrize( ("key", "hint"), - [("voice", "--voice"), ("language", "--language"), ("sample_rate", "fixed")], + [ + ("voice", "--voice"), + ("voice_id", "--voice"), + ("language", "--language"), + ("sample_rate", "fixed"), + ], ) def test_parse_tts_config_rejects_reserved_keys(key, hint): with pytest.raises(UsageError, match=hint): diff --git a/tests/test_agent_cascade_show_code.py b/tests/test_agent_cascade_show_code.py index 97bbe0ff..6f81d898 100644 --- a/tests/test_agent_cascade_show_code.py +++ b/tests/test_agent_cascade_show_code.py @@ -40,7 +40,7 @@ def _boom(**kwargs): assert "streaming.sandbox000" in result.stdout assert "streaming-tts.sandbox000" in result.stdout assert "llm-gateway" in result.stdout - assert "voice=jane" in result.stdout # the chosen voice rides the TTS URL + assert "voice_id=aai-jane" in result.stdout # the chosen voice rides the TTS URL as its id assert "Hi there" in result.stdout # the greeting is injected compile(result.stdout, "", "exec") # the script is runnable Python diff --git a/tests/test_agent_cascade_voices.py b/tests/test_agent_cascade_voices.py index 8d126ed8..8583a599 100644 --- a/tests/test_agent_cascade_voices.py +++ b/tests/test_agent_cascade_voices.py @@ -2,8 +2,16 @@ from __future__ import annotations +import dataclasses + +import pytest + from aai_cli.agent_cascade import voices +from aai_cli.app.context import AppState +from aai_cli.commands.agent_cascade._exec import run_agent_cascade +from aai_cli.core.errors import CLIError, UsageError from aai_cli.tts import voices as tts_voices +from tests.test_agent_cascade_command import _DEFAULTS def test_voice_names_are_the_tts_catalog_sorted(): @@ -20,12 +28,14 @@ def test_complete_voice_filters_by_prefix(): assert all(name.startswith("ja") for name in completions) -def test_format_voice_list_groups_by_language(): - listing = voices.format_voice_list() - blocks = {block.split(":", 1)[0]: block for block in listing.split("\n\n")} - # Each voice is filed strictly under the language it actually speaks: the English - # block lists jane but not the Italian-only giovanni, and vice versa. - assert "jane" in blocks["English"] - assert "giovanni" not in blocks["English"] - assert "giovanni" in blocks["Italian"] - assert "jane" not in blocks["Italian"] +def test_voice_id_form_validates_as_its_catalog_name(): + # A voice_id copied from --list-voices (aai-jane) is the catalog voice jane, so it + # passes validation and the run proceeds to the next guard (the sandbox check). + with pytest.raises(CLIError, match="sandbox"): + run_agent_cascade( + dataclasses.replace(_DEFAULTS, voice="aai-jane"), AppState(), json_mode=False + ) + with pytest.raises(UsageError, match="Unknown voice"): + run_agent_cascade( + dataclasses.replace(_DEFAULTS, voice="aai-nope"), AppState(), json_mode=False + ) diff --git a/tests/test_code_gen_agent_cascade.py b/tests/test_code_gen_agent_cascade.py index 8dfbe0dc..40b5f838 100644 --- a/tests/test_code_gen_agent_cascade.py +++ b/tests/test_code_gen_agent_cascade.py @@ -53,7 +53,7 @@ def test_render_parses_and_wires_all_three_legs(): def test_render_injects_session_knobs(): code = _render(model="claude-x", max_tokens=321, max_history=12) ast.parse(code) - assert "voice=jane" in code # the voice rides the TTS URL + assert "voice_id=aai-jane" in code # the voice rides the TTS URL as its voice_id assert "Be terse." in code # the system prompt assert "Hi there" in code # the greeting assert '"claude-x"' in code # the LLM model diff --git a/tests/test_init_template_agent_cascade.py b/tests/test_init_template_agent_cascade.py index a08313ce..af40870a 100644 --- a/tests/test_init_template_agent_cascade.py +++ b/tests/test_init_template_agent_cascade.py @@ -32,7 +32,7 @@ def test_settings_imports_without_key_or_tts_host(monkeypatch): settings = _load("api.settings", monkeypatch) assert settings.API_KEY == "" assert settings.MODEL == "claude-haiku-4-5-20251001" - assert settings.VOICE == "jane" + assert settings.VOICE_ID == "aai-jane" assert settings.INPUT_SAMPLE_RATE == 16000 assert settings.OUTPUT_SAMPLE_RATE == 24000 @@ -114,11 +114,11 @@ def test_tts_url_carries_voice_and_rate(monkeypatch: pytest.MonkeyPatch) -> None cascade = _cascade(monkeypatch) settings = reimport("api.settings") settings.TTS_HOST = "tts.example" - settings.VOICE = "jane" + settings.VOICE_ID = "aai-jane" settings.OUTPUT_SAMPLE_RATE = 24000 url = cascade.tts_url(settings) assert url.startswith("wss://tts.example/v1/ws/?") - assert "voice=jane" in url + assert "voice_id=aai-jane" in url assert "sample_rate=24000" in url diff --git a/tests/test_init_template_agent_cascade_api.py b/tests/test_init_template_agent_cascade_api.py index 85d32cdf..9a985aa2 100644 --- a/tests/test_init_template_agent_cascade_api.py +++ b/tests/test_init_template_agent_cascade_api.py @@ -49,7 +49,7 @@ def test_connect_tts_passes_max_size_none(monkeypatch: pytest.MonkeyPatch) -> No settings = reimport("api.settings") settings.API_KEY = "sk-test" settings.TTS_HOST = "tts.example" - settings.VOICE = "jane" + settings.VOICE_ID = "aai-jane" settings.OUTPUT_SAMPLE_RATE = 24000 captured = untyped_bag() diff --git a/tests/test_list_voices.py b/tests/test_list_voices.py new file mode 100644 index 00000000..6060833b --- /dev/null +++ b/tests/test_list_voices.py @@ -0,0 +1,86 @@ +"""The shared ``--list-voices`` flag of the streaming-TTS commands (speak, dub, live): +one eager Typer option (aai_cli/options.py) over one body (aai_cli/app/tts_voices.py), +so the three are driven together here.""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from aai_cli.core import config +from aai_cli.main import app +from aai_cli.tts import voice_list + +# Sandbox-only commands run under --sandbox, which the root callback gates on an +# AssemblyAI login — run the whole module as an employee. +pytestmark = pytest.mark.usefixtures("internal_profile") + +runner = CliRunner() + +COMMANDS = ["speak", "dub", "live"] +VOICES = [ + {"voice_id": "aai-jane", "name": "Jane", "language": "en", "tier": "preset"}, + {"voice_id": "aai-giovanni", "language": "it"}, +] + + +@pytest.fixture +def fake_fetch(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "resolve_api_key", lambda **_: "test-key") + seen: dict[str, object] = {} + + def _fetch(api_key: str): + seen["api_key"] = api_key + return VOICES + + monkeypatch.setattr(voice_list, "fetch_voices", _fetch) + return seen + + +@pytest.mark.parametrize("command", COMMANDS) +def test_lists_the_service_voices(command, fake_fetch): + # No other argument/option is needed — notably `dub` needs no --lang, because the + # eager flag exits before the required option is checked. + result = runner.invoke(app, ["--sandbox", command, "--list-voices"]) + assert result.exit_code == 0, result.output + assert fake_fetch["api_key"] == "test-key" + assert "aai-jane Jane · en" in result.output + assert "aai-giovanni it" in result.output + + +@pytest.mark.parametrize("command", COMMANDS) +def test_json_emits_the_raw_listing(command, fake_fetch): + # --json is honored even though it parses after the eager flag: the raw server + # records are dumped untouched (unknown fields like "tier" survive). + result = runner.invoke(app, ["--sandbox", command, "--list-voices", "--json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == VOICES + assert "Jane · en" not in result.output + + +@pytest.mark.parametrize("command", COMMANDS) +def test_production_env_is_rejected_with_the_command_named(command, fake_fetch): + result = runner.invoke(app, [command, "--list-voices"]) # default = production + assert result.exit_code == 2 + assert "only available in the sandbox" in result.output + assert f"Re-run as: assembly --sandbox {command}" in result.output + assert "api_key" not in fake_fetch # refused before any request + + +def test_wins_over_an_invalid_earlier_option(fake_fetch): + # Like --help, the flag is eager: it is processed before the options that precede + # it on the line, so an invalid one (--sample-rate must be positive) never gets + # to reject the invocation. + result = runner.invoke(app, ["--sandbox", "speak", "--sample-rate", "0", "--list-voices"]) + assert result.exit_code == 0, result.output + assert "aai-jane" in result.output + + +def test_flag_off_runs_the_command_normally(fake_fetch): + # Without the flag the eager callback is a pass-through: the command's own + # validation runs (dub's required --lang is reported, no listing is fetched). + result = runner.invoke(app, ["--sandbox", "dub", "talk.mp4"]) + assert result.exit_code == 2 + assert "api_key" not in fake_fetch diff --git a/tests/test_speak.py b/tests/test_speak.py index 2cf1ae71..36603254 100644 --- a/tests/test_speak.py +++ b/tests/test_speak.py @@ -223,7 +223,7 @@ def test_voice_and_language_flow_into_config(monkeypatch, fake_synthesize): cfg = fake_synthesize["cfg"] assert cfg.voice == "jane" assert cfg.language == "English" - assert cfg.query_params() == {"voice": "jane", "language": "English"} + assert cfg.query_params() == {"voice_id": "aai-jane", "language": "English"} def test_default_voice_follows_the_language(monkeypatch, fake_synthesize): diff --git a/tests/test_tts_session.py b/tests/test_tts_session.py index cc3a11e4..e0a393ac 100644 --- a/tests/test_tts_session.py +++ b/tests/test_tts_session.py @@ -32,7 +32,10 @@ def test_ws_url_includes_set_params_only(): cfg = session.SpeakConfig(text="hi", voice="jane", language="English") url = session.ws_url(cfg.query_params()) assert url.startswith("wss://streaming-tts.sandbox000.assemblyai-labs.com/v1/ws/?") - assert "voice=jane" in url + # The voice rides as voice_id=aai-: the bare voice= param is retired server-side + # (3006 invalid_parameter), so it must not be sent even alongside the new one. + assert "voice_id=aai-jane" in url + assert "voice=jane" not in url assert "language=English" in url assert "sample_rate" not in url diff --git a/tests/test_tts_session_dialogue.py b/tests/test_tts_session_dialogue.py index bc8ee8c2..249f1d10 100644 --- a/tests/test_tts_session_dialogue.py +++ b/tests/test_tts_session_dialogue.py @@ -25,8 +25,8 @@ def _connect(url: str, **_kwargs): connect=_connect, ) # Each segment connected with its own voice. - assert "voice=jane" in urls[0] - assert "voice=michael" in urls[1] + assert "voice_id=aai-jane" in urls[0] + assert "voice_id=aai-michael" in urls[1] # 0.25 s of silence (24000 * 0.25 * 2 = 12000 zero bytes) sits BETWEEN the two # segments' PCM, with none at the ends. gap = b"\x00" * 12000 diff --git a/tests/test_tts_voice_list.py b/tests/test_tts_voice_list.py new file mode 100644 index 00000000..5ab121d4 --- /dev/null +++ b/tests/test_tts_voice_list.py @@ -0,0 +1,149 @@ +"""The streaming-TTS voice listing boundary (aai_cli/tts/voice_list.py): the +GET /v1/voices request shape, error normalization, response parsing, and the +human rendering.""" + +from __future__ import annotations + +import httpx2 as httpx +import pytest + +from aai_cli.core import environments +from aai_cli.core.errors import APIError, NotAuthenticated +from aai_cli.tts import voice_list + +VOICES = [ + {"voice_id": "aai-jane", "name": "Jane", "language": "en"}, + {"voice_id": "aai-giovanni", "name": "Giovanni", "language": "it"}, +] + + +@pytest.fixture(autouse=True) +def _sandbox(): + environments.set_active(environments.get("sandbox000")) + + +def _patch_transport(monkeypatch, handler): + real_client = httpx.Client + seen_kwargs = {} + + def fake_client(*args, **kwargs): + seen_kwargs.update(kwargs) + kwargs["transport"] = httpx.MockTransport(handler) + return real_client(*args, **kwargs) + + monkeypatch.setattr(voice_list.httpx, "Client", fake_client) + return seen_kwargs + + +def _respond(status: int, *, json: object = None, text: str | None = None): + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["method"] = request.method + seen["auth"] = request.headers.get("authorization") + if text is not None: + return httpx.Response(status, text=text) + return httpx.Response(status, json=json) + + return handler, seen + + +def test_voices_url_is_the_active_tts_host(): + assert voice_list.voices_url() == ( + "https://streaming-tts.sandbox000.assemblyai-labs.com/v1/voices" + ) + + +def test_fetch_gets_the_listing_with_the_raw_api_key(monkeypatch): + handler, seen = _respond(200, json=VOICES) + client_kwargs = _patch_transport(monkeypatch, handler) + assert voice_list.fetch_voices("sk_voices") == VOICES + assert seen["method"] == "GET" + assert seen["url"] == "https://streaming-tts.sandbox000.assemblyai-labs.com/v1/voices" + # Streaming TTS takes the raw key, not a Bearer token — same as its socket. + assert seen["auth"] == "sk_voices" + assert client_kwargs["timeout"] == 15.0 + + +def test_fetch_unwraps_an_object_envelope(monkeypatch): + # A {"voices": [...]} envelope yields the same list as a bare array. + handler, _ = _respond(200, json={"voices": VOICES}) + _patch_transport(monkeypatch, handler) + assert voice_list.fetch_voices("k") == VOICES + + +@pytest.mark.parametrize("status", [401, 403]) +def test_rejected_key_is_not_authenticated(monkeypatch, status): + handler, _ = _respond(status, json={"error": "nope"}) + _patch_transport(monkeypatch, handler) + with pytest.raises(NotAuthenticated): + voice_list.fetch_voices("bad") + + +def test_http_error_carries_status_and_body(monkeypatch): + handler, _ = _respond(500, text="upstream exploded") + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError, match=r"\(500\): upstream exploded"): + voice_list.fetch_voices("k") + + +def test_http_error_with_empty_body_names_the_status(monkeypatch): + handler, _ = _respond(400, text="") + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError, match=r"\(400\): HTTP 400"): + voice_list.fetch_voices("k") + + +def test_transport_failure_is_an_api_error(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom", request=request) + + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError, match="Could not reach the TTS service: boom"): + voice_list.fetch_voices("k") + + +def test_non_json_body_is_an_api_error(monkeypatch): + handler, _ = _respond(200, text="") + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError, match="not valid JSON"): + voice_list.fetch_voices("k") + + +@pytest.mark.parametrize( + "body", + [ + {"items": VOICES}, # an envelope under the wrong key + {"voices": "jane"}, # the right key, not a list + ["jane", "michael"], # a list, but of strings + "jane", + ], +) +def test_wrong_shape_is_an_api_error(monkeypatch, body): + handler, _ = _respond(200, json=body) + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError, match="unexpected shape"): + voice_list.fetch_voices("k") + + +def test_format_pads_ids_and_joins_the_known_details(): + listing: list[dict[str, object]] = [ + {"voice_id": "aai-jane", "name": "Jane", "language": "en", "description": "warm"}, + {"voice_id": "aai-giovanni", "language": "it", "extra": "ignored"}, + ] + # Ids are left-aligned to the widest one; details join with " · " in a fixed + # order (name, language, description), skipping absent/empty fields and + # unknown keys. Trailing whitespace is trimmed on a details-less line. + assert voice_list.format_voice_list(listing) == ( + "aai-jane Jane · en · warm\naai-giovanni it" + ) + + +def test_format_without_details_or_id_is_still_one_line_per_voice(): + # A record missing its voice_id renders a "?" placeholder; an empty detail is skipped. + assert voice_list.format_voice_list([{"voice_id": "aai-x"}, {"name": ""}]) == "aai-x\n?" + + +def test_format_empty_listing(): + assert voice_list.format_voice_list([]) == "No voices available." diff --git a/tests/test_tts_voices.py b/tests/test_tts_voices.py index 2024547b..50518a87 100644 --- a/tests/test_tts_voices.py +++ b/tests/test_tts_voices.py @@ -97,3 +97,23 @@ def test_rotation_for(language, expected): ) def test_default_voice(language, expected): assert voices.default_voice(language) == expected + + +@pytest.mark.parametrize( + ("voice", "expected"), + [ + ("jane", "aai-jane"), # a catalog name gets the service's id prefix + ("bill_boerst", "aai-bill_boerst"), + ("aai-jane", "aai-jane"), # an id copied from --list-voices passes through + ], +) +def test_voice_id(voice, expected): + assert voices.voice_id(voice) == expected + + +@pytest.mark.parametrize( + ("voice", "expected"), + [("aai-jane", "jane"), ("jane", "jane"), ("aai-aai-x", "aai-x")], # one prefix, not all +) +def test_voice_name(voice, expected): + assert voices.voice_name(voice) == expected From aec2b01c83e1b2b0fe3cfe5ec19ff908e5f71178 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 22 Oct 2026 12:42:35 -0700 Subject: [PATCH 2/3] Run TTS and dictation against production, per the AssemblyAI docs Streaming TTS is now in production, and both voice APIs are called exactly as documented. Streaming TTS (https://www.assemblyai.com/docs/tts): - Production gets a TTS host, wss://tts.us.assemblyai.com/v1/ws, so speak, dub, and live run without --sandbox (the [sandbox] help tag and the sandbox-only copy are gone). - The catalog is the 11 documented built-in voices, each of which speaks all six languages; the old 26-voice single-language catalog listed 10 voices the service doesn't have (incl. juergen, the German default) and mislabeled others. `language` is sent as the lowercase English name (`de` -> `german`) and `encoding=pcm_s16le` is pinned. A non-built-in --voice (UUID, cloned voice slug) passes through unprefixed. - Long text goes out as bounded Generate+Flush requests (<=3 in flight) on one socket instead of a socket per chunk (which hits 3009), and sessions end Terminate -> Termination. Unknown frames are ignored; is_final is gone. - --list-voices shows the built-ins plus the account's own voices from GET /v1/voices (records keyed by slug, paginated via next_cursor). - The agent-cascade template stopped waiting on is_final (the service never sends it), which hung each sentence until the idle timeout; it now ends on FlushDone. Dictation (https://www.assemblyai.com/docs/dictation): - `assembly dictate` moves from the Sync STT API to the Dictation API: POST dictation.assemblyai.com/v1/transcribe/live with the config part first (audio-first is a 400), language_codes/stt_prompt/keyterms_prompt. - It prints the send-ready cleanup rewrite by default (falling back to the verbatim transcript, with a warning, when the rewrite fails); --verbatim, --instruction, and --rewrite-mode are new; --keyterm replaces --word-boost (kept as a hidden deprecated alias). --json adds llm_response/llm_error. Models: live defaults to claude-haiku-4-5-20251001 (kimi-k2.5 is not served by the production gateway); retired ids drop out of the known-models list. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 +- aai_cli/AGENTS.md | 8 +- aai_cli/agent_cascade/__init__.py | 3 +- aai_cli/agent_cascade/config.py | 6 +- aai_cli/agent_cascade/voices.py | 4 +- aai_cli/app/init_exec.py | 4 +- aai_cli/app/tts_voices.py | 10 +- aai_cli/code_gen/agent_cascade.py | 11 +- aai_cli/code_gen/agent_cascade_body.py | 2 - aai_cli/commands/agent_cascade/__init__.py | 20 +- aai_cli/commands/agent_cascade/_exec.py | 1 + aai_cli/commands/dictate/__init__.py | 51 +++- aai_cli/commands/dictate/_exec.py | 92 ++++--- aai_cli/commands/dub/__init__.py | 29 +-- aai_cli/commands/dub/_exec.py | 2 +- aai_cli/commands/dub/_pipeline.py | 10 +- aai_cli/commands/speak/__init__.py | 26 +- aai_cli/commands/speak/_exec.py | 12 +- aai_cli/core/choices.py | 7 + aai_cli/core/dictation.py | 163 ++++++++++++ aai_cli/core/environments.py | 10 +- aai_cli/core/llm.py | 5 +- aai_cli/core/sync_stt.py | 141 ----------- aai_cli/init/templates/__init__.py | 2 +- .../init/templates/agent_cascade/README.md | 16 +- .../templates/agent_cascade/api/cascade.py | 27 +- .../init/templates/agent_cascade/api/index.py | 3 +- .../templates/agent_cascade/api/settings.py | 15 +- .../init/templates/agent_cascade/env.example | 10 +- aai_cli/options.py | 4 +- aai_cli/tts/session.py | 216 +++++++++------- aai_cli/tts/text.py | 15 +- aai_cli/tts/voice_list.py | 79 ++++-- aai_cli/tts/voices.py | 147 +++++------ examples/dictation-hammerspoon/README.md | 4 +- examples/dictation-hammerspoon/dictation.lua | 2 +- scripts/generated_code_compile_gate.py | 2 - .../test_snapshots_help_root.ambr | 7 +- .../test_snapshots_help_run.ambr | 166 +++++++------ tests/_tts_session_helpers.py | 36 +-- tests/test_agent_cascade_command.py | 9 +- tests/test_agent_cascade_config.py | 2 +- tests/test_agent_cascade_show_code.py | 18 +- tests/test_agent_cascade_voices.py | 10 +- tests/test_code_gen_agent_cascade.py | 4 +- tests/test_command_options_seam.py | 7 +- tests/test_dictate_command.py | 25 +- tests/test_dictate_exec.py | 123 +++++++--- tests/test_dictation.py | 231 ++++++++++++++++++ tests/test_dub_command.py | 14 +- tests/test_dub_exec.py | 13 +- tests/test_dub_pipeline.py | 18 +- tests/test_environments.py | 14 +- tests/test_init_template_agent_cascade.py | 23 +- tests/test_init_template_agent_cascade_api.py | 2 +- .../test_init_template_agent_cascade_reply.py | 25 +- tests/test_list_voices.py | 37 +-- tests/test_llm_command.py | 5 +- tests/test_sandbox_access.py | 11 +- tests/test_speak.py | 62 ++--- tests/test_sync_stt.py | 222 ----------------- tests/test_tts_session.py | 133 +++++----- tests/test_tts_session_dialogue.py | 10 +- tests/test_tts_session_multi.py | 165 +++++++++++++ tests/test_tts_voice_list.py | 89 +++++-- tests/test_tts_voices.py | 118 ++++----- 66 files changed, 1580 insertions(+), 1197 deletions(-) create mode 100644 aai_cli/core/dictation.py delete mode 100644 aai_cli/core/sync_stt.py create mode 100644 tests/test_dictation.py delete mode 100644 tests/test_sync_stt.py create mode 100644 tests/test_tts_session_multi.py diff --git a/README.md b/README.md index 2612d3e7..9e1b92c5 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,13 @@ That's it. Run `assembly onboard` for a guided tour, or see [Installation](#-ins | :--- | :--- | | `assembly transcribe` | Transcribe files, URLs, YouTube/podcast pages, podcast RSS feeds, directories, globs, or bucket storage (`s3://`, `gs://`, `az://`) — with speaker labels, PII redaction, summarization, SRT/VTT captions, and resumable batch runs | | `assembly stream` | Real-time transcription from your microphone, a file, or a URL — on macOS it can capture system audio too | -| `assembly dictate` | Signal-driven dictation: records immediately, send SIGTERM for instant text — scriptable from hotkey tools like Hammerspoon (Sync STT API, up to 120 s per utterance) | +| `assembly dictate` | Signal-driven dictation: records immediately, send SIGTERM for instant text — scriptable from hotkey tools like Hammerspoon; prints send-ready cleaned-up text (Dictation API, up to 120 s per utterance) | | `assembly agent` | Full-duplex spoken conversation with a voice agent, right in your terminal | -| `assembly live` | Talk live to a tool-using voice agent, wired client-side from Streaming STT + a deepagents brain on the LLM Gateway + streaming TTS — it can web-search, fetch URLs, and read the docs mid-conversation, like the `agent-cascade` starter (sandbox-only) | -| `assembly speak` | Synthesize text to speech over the streaming-TTS WebSocket (sandbox-only) | +| `assembly live` | Talk live to a tool-using voice agent, wired client-side from Streaming STT + a deepagents brain on the LLM Gateway + streaming TTS — it can web-search, fetch URLs, and read the docs mid-conversation, like the `agent-cascade` starter | +| `assembly speak` | Synthesize text to speech over the streaming-TTS WebSocket | | `assembly llm` | Prompt the LLM Gateway over a transcript, files, stdin, or a live stream | | `assembly clip` | Cut audio/video with ffmpeg by diarized speaker, text match, LLM pick, or time range (`--video` keeps the picture for URL sources) — clip boundaries snap into nearby silence | -| `assembly dub` | Re-voice an audio/video file or URL in another language: transcription, LLM translation, per-speaker TTS, ffmpeg track-swap (sandbox-only) | +| `assembly dub` | Re-voice an audio/video file or URL in another language: transcription, LLM translation, per-speaker TTS, ffmpeg track-swap | | `assembly caption` | Burn always-visible captions into a video: transcribe (or reuse a transcript), fetch SRT, ffmpeg burns it in — audio untouched | | `assembly eval` | Benchmark WER against Hugging Face datasets (built-in aliases: `librispeech`, `tedlium`, …) or local manifests | | `assembly webhooks listen` | Open a public dev URL that prints webhook deliveries and can forward them to your local app | @@ -68,23 +68,20 @@ Add `--show-code` to `transcribe` / `stream` / `agent` / `live` to print the equ A few one-liners that show what `assembly` can do. The everyday basics live under [Getting started](#-getting-started) below. -> [!NOTE] -> `speak` and `dub` are sandbox-only today — that's why the examples below pass `--sandbox`. - **Recreate a scene with synthetic voices** — transcribe and diarize a YouTube clip, then pipe it straight into TTS with a different voice per speaker: ```sh assembly transcribe "https://www.youtube.com/watch?v=awmCtXzFsJo" --speaker-labels \ - | assembly --sandbox speak --voice A=jane --voice B=mary --out scene.wav + | assembly speak --voice A=jane --voice B=mary --out scene.wav ``` `speak` auto-detects `Speaker A:` labels, merges each speaker's turns, and rotates voices. -`assembly --sandbox speak --list-voices` prints the voices the TTS service offers. +`assembly speak --list-voices` prints the voices the TTS service offers. **Dub a video into another language** — the whole platform in one command: transcription with utterance timestamps, per-utterance LLM translation, TTS for each line (one voice per speaker), and ffmpeg laying the new track over the original video. A great demo is the first YouTube video ever, "Me at the zoo" — it's 19 seconds long, a single clear English speaker, and instantly recognizable, so the dub finishes fast and the before/after is obvious: ```sh -assembly --sandbox dub "https://www.youtube.com/watch?v=jNQXAC9IVRw" -l de --video +assembly dub "https://www.youtube.com/watch?v=jNQXAC9IVRw" -l de --video ``` The video stream is copied untouched; each dubbed line lands at its original start time. @@ -93,7 +90,7 @@ The video stream is copied untouched; each dubbed line lands at its original sta ```sh assembly transcribe "https://podcasts.apple.com/us/podcast/id1516093381" --speaker-labels \ - | assembly --sandbox speak --out episode.wav + | assembly speak --out episode.wav ``` **Cut the highlight reel from a speech** — `clip` downloads the video (`--video`; omit it for audio-only clips), transcribes it, has an LLM pick the windows, and cuts each one into its own file with ffmpeg (here: Steve Jobs' Stanford commencement address): diff --git a/aai_cli/AGENTS.md b/aai_cli/AGENTS.md index a9a4b580..816589cb 100644 --- a/aai_cli/AGENTS.md +++ b/aai_cli/AGENTS.md @@ -32,7 +32,7 @@ between layers is enforced — higher may import lower, never the reverse: `help_text`, `typer_patches`, `update_check`. - **`core/`** — the Rich-free library layer: `client`, `config`, `config_builder`, `keyring_store`, `environments`, `env`, `errors`, `llm`, - `telemetry`, `debuglog`, `remotefs`, `sync_stt`, `signals`, `ws`, `youtube`, + `telemetry`, `debuglog`, `remotefs`, `dictation`, `signals`, `ws`, `youtube`, `wer`, `argscan`, `jsonshape`, `timeparse`, `microphone`, `procs`, `stdio`, `choices`, `ssrf` (the outbound-fetch SSRF guard: resolves a URL's host and refuses private/loopback/link-local IPs, re-checked on every redirect hop — @@ -151,10 +151,10 @@ heavily-reworked commands with long bodies; small commands keep the inline ### Feature subsystems - **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback. -- **`core/sync_stt.py`** + **`core/signals.py`** + `commands/dictate/` — `assembly dictate`: headless dictation over the **Sync STT API** (`Environment.sync_base`, one POST `/transcribe` per utterance with the required `X-AAI-Model: u3-sync-pro` header; 80 ms–120 s of PCM/WAV). It needs no terminal: recording starts immediately and `dictate_exec._record` polls `signals.stop_on_terminate` between ~100 ms mic chunks for a SIGTERM, which finishes the utterance (clean exit 0) — so a hotkey tool like Hammerspoon can launch it as a background task and `kill -TERM`/`task:terminate()` to transcribe. SIGINT (Ctrl-C) still cancels (exit 130). Both boundaries (the stop latch, mic, HTTP) are injectable, so the suite never needs a real signal or microphone (`tests/test_dictate_exec.py` scripts the SIGTERM latch). Contrast `signals.terminate_as_interrupt` (used by `stream`/`agent`/`speak`), which routes SIGTERM into the *cancel* path instead. +- **`core/dictation.py`** + **`core/signals.py`** + `commands/dictate/` — `assembly dictate`: headless dictation over the **Dictation API** (; `Environment.dictation_base`, one multipart POST `/v1/transcribe/live` per utterance — the JSON `config` part **must precede** the raw-PCM `audio` part or the server answers 400; up to 120 s). The response carries the verbatim `text` and the send-ready cleanup rewrite `llm_response` (best-effort: on `llm_error` it is null and `DictationResult.final_text` falls back to `text`); dictate prints the rewrite by default, `--verbatim` the transcript, and `--instruction`/`--rewrite-mode` map to `llm_instruction`/`rewrite_mode`. `--keyterm` is `keyterms_prompt` (`--word-boost` is a hidden deprecated alias). It needs no terminal: recording starts immediately and `dictate_exec._record` polls `signals.stop_on_terminate` between ~100 ms mic chunks for a SIGTERM, which finishes the utterance (clean exit 0) — so a hotkey tool like Hammerspoon can launch it as a background task and `kill -TERM`/`task:terminate()` to transcribe. SIGINT (Ctrl-C) still cancels (exit 130). Both boundaries (the stop latch, mic, HTTP) are injectable, so the suite never needs a real signal or microphone (`tests/test_dictate_exec.py` scripts the SIGTERM latch). Contrast `signals.terminate_as_interrupt` (used by `stream`/`agent`/`speak`), which routes SIGTERM into the *cancel* path instead. - **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). -- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph **assembled** by `brain.py` (tools, backend, middleware, `build_graph`, plus the shared `SpeechDelta`/`ToolNotice`/`ApprovalPause` event types and tool-affordance vocabulary) and **driven** turn-by-turn by `streamer.py` (`build_streamer`, `graph.stream(stream_mode="messages")`) — the natural build-vs-drive seam, split so each file stays under the 500-line gate (the dependency is one-directional: `streamer` imports `brain`, never the reverse). **Context-window management is the graph's job, not the engine's** — `create_deep_agent` wires deepagents' own `SummarizationMiddleware` into the stack (summarize the oldest turns, offload the evicted history to a file), so the engine feeds the *full* untrimmed running history each turn and lets the graph compact it; the old client-side `text.trim_history`/`config.max_history` sliding window is gone from this path (`max_history` now only drives the hand-rolled `--show-code`/`assembly init` cascade, which doesn't use deepagents). The engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. **The `write_todos` plan** (deepagents auto-installs langchain's `TodoListMiddleware`) is reassembled from the streamed call args by `plan.py`'s `TodoCollector` and surfaced as a `plan.TodoUpdate` (not a `ToolNotice`); `engine._consume` forwards it to `renderer.todos_updated` without gating speech, so the voice TUI shows an in-place `messages.TodoList` checklist (and `AgentRenderer` emits a `plan` NDJSON event / a stderr checklist) — a visible plan to go with the spoken "first I'll…, then I'll…". Under `-v` (`debuglog.active()`) `streamer._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`. The write gate is **path-scoped (the allow/interrupt permission model), not all-or-nothing**: `write_gate.write_interrupt_on(config.auto_write_paths)` hands `write_file`/`edit_file` one `InterruptOnConfig` whose `when` predicate fires only for writes *outside* every `--auto-write` subtree, so a write inside one runs ungated while others still pause (empty paths gate every write, the prior behavior). The matching is a transparent posix path-segment prefix check — `--auto-write DIR` is a directory subtree, and reads stay ungated, so no glob/bulk-tool machinery is needed (and no reach into deepagents internals: `create_deep_agent(permissions=…)` is unusable here — it raises `NotImplementedError` against an execute-capable backend like our `SandboxedShellBackend`). A non-string or `..`-containing target fails safe to gating. `execute` stays a plain `True` (unconditionally gated) — a command isn't a single file path to scope. `--auto-write DIR` is normalized to a cwd-rooted virtual root in `_exec._normalize_auto_write` (rejecting `..`/`~`/whole-tree aliases) and requires `--files`. `streamer._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). **Project grounding (independent of `--files`):** `_exec.run_agent_cascade` reads the launch directory's `AGENTS.md`/`CLAUDE.md` via `agent_cascade/project_context.load_project_context()` into `CascadeConfig.project_context`, which `brain.build_graph` threads into `prompt.build_system_prompt(..., project_context=…)` (appended as project background after the persona/tool guidance). `AGENTS.md` wins precedence, identical content (a symlinked `CLAUDE.md`) is de-duplicated, and the total is capped at `project_context.MAX_CONTEXT_CHARS`. It's read at the command boundary (not in `build_graph`) so the brain stays hermetic, and the `--show-code` path builds its own config without it. Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`. The gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool) for delegating a focused subtask is **auto-added by deepagents** — we don't declare it. We only override its prose for a voice turn (a spoken-length summary, not the SDK's "complete answer" default) via a harness profile keyed by the gateway model's provider (`subagents.register_gp_subagent_profile`, called from `build_graph` so the deepagents import stays lazy — and kept off `brain.py`, which sits at the 500-line gate). It inherits the gateway-bound model, the sandboxed toolset, *and* the top-level `interrupt_on` (deepagents' `graph.py` merges the top-level config into the auto-added subagent), so a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` with no per-subagent restatement (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated. -- **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). **The voice rides the socket URL as `voice_id=aai-`** (`voices.voice_id`; the bare `voice=` param is retired server-side and answers 3006 invalid_parameter) — `SpeakConfig.query_params` is the one place that encodes it, so `speak`/`dub`/`live` can't drift; the static `voices.py` catalog only drives language defaults + completion, while `--list-voices` on all three commands is the service's live `GET /v1/voices` (`tts/voice_list.py` fetches, `app/tts_voices.py` emits, and `options.list_voices_option` is the shared flag that does the work in its callback and exits — so `dub --list-voices` needs no `--lang` — and is eager so it wins over an invalid earlier option, like `--help`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless. +- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph **assembled** by `brain.py` (tools, backend, middleware, `build_graph`, plus the shared `SpeechDelta`/`ToolNotice`/`ApprovalPause` event types and tool-affordance vocabulary) and **driven** turn-by-turn by `streamer.py` (`build_streamer`, `graph.stream(stream_mode="messages")`) — the natural build-vs-drive seam, split so each file stays under the 500-line gate (the dependency is one-directional: `streamer` imports `brain`, never the reverse). **Context-window management is the graph's job, not the engine's** — `create_deep_agent` wires deepagents' own `SummarizationMiddleware` into the stack (summarize the oldest turns, offload the evicted history to a file), so the engine feeds the *full* untrimmed running history each turn and lets the graph compact it; the old client-side `text.trim_history`/`config.max_history` sliding window is gone from this path (`max_history` now only drives the hand-rolled `--show-code`/`assembly init` cascade, which doesn't use deepagents). The engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. **The `write_todos` plan** (deepagents auto-installs langchain's `TodoListMiddleware`) is reassembled from the streamed call args by `plan.py`'s `TodoCollector` and surfaced as a `plan.TodoUpdate` (not a `ToolNotice`); `engine._consume` forwards it to `renderer.todos_updated` without gating speech, so the voice TUI shows an in-place `messages.TodoList` checklist (and `AgentRenderer` emits a `plan` NDJSON event / a stderr checklist) — a visible plan to go with the spoken "first I'll…, then I'll…". Under `-v` (`debuglog.active()`) `streamer._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`. The write gate is **path-scoped (the allow/interrupt permission model), not all-or-nothing**: `write_gate.write_interrupt_on(config.auto_write_paths)` hands `write_file`/`edit_file` one `InterruptOnConfig` whose `when` predicate fires only for writes *outside* every `--auto-write` subtree, so a write inside one runs ungated while others still pause (empty paths gate every write, the prior behavior). The matching is a transparent posix path-segment prefix check — `--auto-write DIR` is a directory subtree, and reads stay ungated, so no glob/bulk-tool machinery is needed (and no reach into deepagents internals: `create_deep_agent(permissions=…)` is unusable here — it raises `NotImplementedError` against an execute-capable backend like our `SandboxedShellBackend`). A non-string or `..`-containing target fails safe to gating. `execute` stays a plain `True` (unconditionally gated) — a command isn't a single file path to scope. `--auto-write DIR` is normalized to a cwd-rooted virtual root in `_exec._normalize_auto_write` (rejecting `..`/`~`/whole-tree aliases) and requires `--files`. `streamer._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). **Project grounding (independent of `--files`):** `_exec.run_agent_cascade` reads the launch directory's `AGENTS.md`/`CLAUDE.md` via `agent_cascade/project_context.load_project_context()` into `CascadeConfig.project_context`, which `brain.build_graph` threads into `prompt.build_system_prompt(..., project_context=…)` (appended as project background after the persona/tool guidance). `AGENTS.md` wins precedence, identical content (a symlinked `CLAUDE.md`) is de-duplicated, and the total is capped at `project_context.MAX_CONTEXT_CHARS`. It's read at the command boundary (not in `build_graph`) so the brain stays hermetic, and the `--show-code` path builds its own config without it. Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`. The gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool) for delegating a focused subtask is **auto-added by deepagents** — we don't declare it. We only override its prose for a voice turn (a spoken-length summary, not the SDK's "complete answer" default) via a harness profile keyed by the gateway model's provider (`subagents.register_gp_subagent_profile`, called from `build_graph` so the deepagents import stays lazy — and kept off `brain.py`, which sits at the 500-line gate). It inherits the gateway-bound model, the sandboxed toolset, *and* the top-level `interrupt_on` (deepagents' `graph.py` merges the top-level config into the auto-added subagent), so a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` with no per-subagent restatement (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated. +- **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the streaming-TTS WebSocket, called exactly as documented at (`wss://tts.us.assemblyai.com/v1/ws` in production, `streaming-tts.sandbox000.…` in the sandbox; raw key in `Authorization`; `voice_id`/`language` (lowercase English name, `voices.language_param`)/`sample_rate`/`encoding=pcm_s16le` as query params). `session.require_available` only refuses an environment with an empty `Environment.streaming_tts_host` (none today). `session.synthesize` drives Begin → (Generate+Flush → Audio… → FlushDone)×N → Terminate → Termination; `synthesize_chunked` sends a long text as bounded requests (≤3 in flight) on **one** socket — the documented multi-request pattern, since a socket per chunk hits the new-session limit (3009). Unknown frame types (e.g. `WordBoundaries`) are ignored; there is no `is_final`. Every entry point takes an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). **The voice rides the socket URL as `voice_id=aai-`** (`voices.voice_id`; the bare `voice=` param is retired server-side and answers 3006 invalid_parameter) — `SpeakConfig.query_params` is the one place that encodes it, so `speak`/`dub`/`live` can't drift; the static `voices.py` catalog is the 11 documented built-in voices, each of which speaks all six languages (so the default voice/rotation never depends on `--language`; any other `--voice` — a UUID or an account/cloned voice slug — passes through unprefixed), while `--list-voices` on all three commands is the built-ins plus the account's own voices from `GET /v1/voices` (default scope, paginated via `next_cursor`, records keyed by `slug`; `tts/voice_list.py` fetches, `app/tts_voices.py` emits, and `options.list_voices_option` is the shared flag that does the work in its callback and exits — so `dub --list-voices` needs no `--lang` — and is eager so it wins over an invalid earlier option, like `--help`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless. - **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`). - **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps. - **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`. diff --git a/aai_cli/agent_cascade/__init__.py b/aai_cli/agent_cascade/__init__.py index 8807ebfc..26bae65b 100644 --- a/aai_cli/agent_cascade/__init__.py +++ b/aai_cli/agent_cascade/__init__.py @@ -4,8 +4,7 @@ `assembly agent`, but where `agent` talks to AssemblyAI's single Voice Agent endpoint, this slice wires the three primitives together itself — Streaming STT -> the LLM Gateway -> streaming TTS — exactly like the ``agent-cascade`` -``assembly init`` template does server-side. Because it uses streaming TTS it is -sandbox-only. +``assembly init`` template does server-side. `engine.run_cascade` is the orchestrator; it takes injected dependencies (`CascadeDeps`) so tests drive the whole cascade against fakes, the same seam diff --git a/aai_cli/agent_cascade/config.py b/aai_cli/agent_cascade/config.py index 2655164a..568d7c9a 100644 --- a/aai_cli/agent_cascade/config.py +++ b/aai_cli/agent_cascade/config.py @@ -15,8 +15,10 @@ # `assembly live` defaults to a fast, low-latency gateway model (override with --model) — # a literal rather than llm.DEFAULT_MODEL so the live agent's default is independent of the -# one-shot `assembly llm` default. Latency matters most for a spoken back-and-forth. -DEFAULT_MODEL = "kimi-k2.5" +# one-shot `assembly llm` default (they coincide today). Latency matters most for a spoken +# back-and-forth: Haiku 4.5 is among the fastest production models to first token with +# tools bound, and it must be one the production gateway serves. +DEFAULT_MODEL = "claude-haiku-4-5-20251001" DEFAULT_MAX_TOKENS = llm.DEFAULT_MAX_TOKENS # The realtime model the cascade transcribes with (same as the agent-cascade template). DEFAULT_SPEECH_MODEL = "universal-3-5-pro" diff --git a/aai_cli/agent_cascade/voices.py b/aai_cli/agent_cascade/voices.py index ac9f1547..20e9e177 100644 --- a/aai_cli/agent_cascade/voices.py +++ b/aai_cli/agent_cascade/voices.py @@ -12,10 +12,10 @@ from aai_cli.core import choices from aai_cli.tts import voices as tts_voices -DEFAULT_VOICE = "jane" +DEFAULT_VOICE = tts_voices.DEFAULT_VOICE # The selectable voice names, sorted for a stable completion order. -VOICE_NAMES: list[str] = sorted(tts_voices.VOICE_LANGUAGES) +VOICE_NAMES: list[str] = sorted(tts_voices.VOICES) def complete_voice(incomplete: str) -> list[str]: diff --git a/aai_cli/app/init_exec.py b/aai_cli/app/init_exec.py index 45ca204a..b04616f1 100644 --- a/aai_cli/app/init_exec.py +++ b/aai_cli/app/init_exec.py @@ -106,9 +106,7 @@ def _active_env_vars() -> dict[str, str]: # The environment's authoritative Voice Agent host (not derived from the # streaming host, which only coincides by naming convention today). "ASSEMBLYAI_AGENTS_HOST": env.agents_host, - # Streaming-TTS host for the cascade (agent-cascade) template. Empty in - # production, where streaming TTS has no host; that template then refuses to - # run and points at --sandbox. + # Streaming-TTS host for the cascade (agent-cascade) template. "ASSEMBLYAI_TTS_HOST": env.streaming_tts_host, } diff --git a/aai_cli/app/tts_voices.py b/aai_cli/app/tts_voices.py index 8de425e8..4959de74 100644 --- a/aai_cli/app/tts_voices.py +++ b/aai_cli/app/tts_voices.py @@ -1,8 +1,8 @@ """The ``--list-voices`` body shared by the streaming-TTS commands (speak, dub, live). -The listing is the service's ``GET /v1/voices`` (``tts.voice_list``), so it needs the -sandbox and an API key like the synthesis it informs. The Typer flag that invokes -it is ``options.list_voices_option``. +The listing is the built-in voices plus the account's own from the service's +``GET /v1/voices`` (``tts.voice_list``), so it needs an API key like the synthesis +it informs. The Typer flag that invokes it is ``options.list_voices_option``. """ from __future__ import annotations @@ -13,7 +13,7 @@ def emit_voice_list(command: str, state: AppState, *, json_mode: bool) -> None: - """Print the service's voices: the raw JSON array, or one line per voice.""" + """Print the voices: the raw JSON array, or one line per voice.""" session.require_available(command) - listing = voice_list.fetch_voices(state.resolve_api_key()) + listing = voice_list.list_voices(state.resolve_api_key()) output.emit(listing, voice_list.format_voice_list, json_mode=json_mode) diff --git a/aai_cli/code_gen/agent_cascade.py b/aai_cli/code_gen/agent_cascade.py index 331b5dcd..8c4f49e7 100644 --- a/aai_cli/code_gen/agent_cascade.py +++ b/aai_cli/code_gen/agent_cascade.py @@ -6,7 +6,7 @@ from aai_cli.code_gen import agent_cascade_body from aai_cli.core import environments -from aai_cli.tts import voices +from aai_cli.tts import session if TYPE_CHECKING: from aai_cli.agent_cascade.config import CascadeConfig @@ -72,11 +72,10 @@ def _stt_url(speech_model: str, *, format_turns: bool) -> str: def _tts_url(voice: str, language: str | None) -> str: - """The streaming-TTS socket URL for the configured voice (sandbox-only host).""" - params: dict[str, str] = {"voice_id": voices.voice_id(voice), "sample_rate": "24000"} - if language is not None: - params["language"] = language - return f"wss://{environments.active().streaming_tts_host}/v1/ws/?{urlencode(params)}" + """The streaming-TTS socket URL for the configured voice — the CLI's own URL + builder, so the generated script can't drift from what `speak` sends.""" + config = session.SpeakConfig(text="", voice=voice, language=language, sample_rate=24000) + return session.ws_url(config.query_params()) def _cue(*, format_turns: bool) -> tuple[str, str]: diff --git a/aai_cli/code_gen/agent_cascade_body.py b/aai_cli/code_gen/agent_cascade_body.py index a5d68dee..cb8d0c20 100644 --- a/aai_cli/code_gen/agent_cascade_body.py +++ b/aai_cli/code_gen/agent_cascade_body.py @@ -82,8 +82,6 @@ def synthesize(text): kind = frame.get("type") if kind == "Audio": pcm += base64.b64decode(frame.get("audio", "")) - if frame.get("is_final"): - break elif kind in ("FlushDone", "Error"): break ws.send(json.dumps({"type": "Terminate"})) diff --git a/aai_cli/commands/agent_cascade/__init__.py b/aai_cli/commands/agent_cascade/__init__.py index 90933155..e649a906 100644 --- a/aai_cli/commands/agent_cascade/__init__.py +++ b/aai_cli/commands/agent_cascade/__init__.py @@ -46,27 +46,27 @@ def _as_tuple[T](values: list[T] | None) -> tuple[T, ...]: rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( [ - ("Start a live voice conversation", "assembly --sandbox live"), + ("Start a live voice conversation", "assembly live"), ( "Pick a voice and opening line", - 'assembly --sandbox live --voice michael --greeting "Hi there"', + 'assembly live --voice michael --greeting "Hi there"', ), ( "Give the agent a persona", - 'assembly --sandbox live --system-prompt "You are a terse pirate."', + 'assembly live --system-prompt "You are a terse pirate."', ), ( "Add your own MCP servers (none load by default)", - "assembly --sandbox live --mcp-config ~/.config/mcp/servers.json", + "assembly live --mcp-config ~/.config/mcp/servers.json", ), ( "Run a conversation without filesystem access", - "assembly --sandbox live --no-files", + "assembly live --no-files", ), - ("See available voices", "assembly --sandbox live --list-voices"), + ("See available voices", "assembly live --list-voices"), ( "Print equivalent Python instead of running", - "assembly --sandbox live --show-code", + "assembly live --show-code", ), ] ), @@ -197,15 +197,13 @@ def live( help="Print the equivalent Python SDK code and exit (does not start a session)", ), ) -> None: - """\\[sandbox] Talk live to a tool-using voice agent + """Talk live to a tool-using voice agent A real-time spoken conversation, wired client-side from three primitives — Streaming STT, a deepagents brain on the LLM Gateway, and streaming TTS. Unlike 'assembly agent' (the Voice Agent API), the brain here is an agent that can use tools mid-conversation — web search, URL fetch, and the AssemblyAI docs — so it - answers like a live multimodal assistant. Because it uses streaming TTS it only - runs in the sandbox: run it as 'assembly --sandbox live' (--sandbox goes before - the subcommand). + answers like a live multimodal assistant. Use headphones: the mic stays open while the agent speaks, so on speakers it would hear itself and loop. Pass an audio file/URL (or --sample) to speak a diff --git a/aai_cli/commands/agent_cascade/_exec.py b/aai_cli/commands/agent_cascade/_exec.py index 46600296..8796e553 100644 --- a/aai_cli/commands/agent_cascade/_exec.py +++ b/aai_cli/commands/agent_cascade/_exec.py @@ -43,6 +43,7 @@ "voice_id": "Set the voice with --voice, not --tts-config.", "language": "Set the language with --language, not --tts-config.", "sample_rate": "TTS sample rate is fixed to match the live speaker and can't be overridden.", + "encoding": "TTS encoding is fixed to the 16-bit PCM the live speaker plays.", } diff --git a/aai_cli/commands/dictate/__init__.py b/aai_cli/commands/dictate/__init__.py index 75bb119c..4e008788 100644 --- a/aai_cli/commands/dictate/__init__.py +++ b/aai_cli/commands/dictate/__init__.py @@ -6,7 +6,7 @@ from aai_cli.app.context import run_with_options from aai_cli.commands.dictate import _exec as dictate_exec from aai_cli.core import choices -from aai_cli.core.sync_stt import MAX_AUDIO_SECONDS +from aai_cli.core.dictation import MAX_AUDIO_SECONDS from aai_cli.ui.help_text import examples_epilog app = typer.Typer() @@ -22,7 +22,7 @@ rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( [ - ("Record until SIGTERM, then print the transcript", "assembly dictate"), + ("Record until SIGTERM, then print the cleaned-up text", "assembly dictate"), ( "Stop the recording and transcribe (e.g. from a hotkey tool)", "kill -TERM $(pgrep -f 'assembly dictate')", @@ -34,8 +34,13 @@ ("Dictate in Spanish", "assembly dictate --language es"), ( "Bias recognition toward tricky terms", - "assembly dictate --word-boost AssemblyAI --word-boost LeMUR", + "assembly dictate --keyterm AssemblyAI --keyterm LeMUR", ), + ( + "Reshape the text instead of the default cleanup", + 'assembly dictate --instruction "Format as a bulleted task list"', + ), + ("Print the verbatim transcript, unedited", "assembly dictate --verbatim"), ("Emit the utterance as a JSON object", "assembly dictate --json"), ("Pipe the bare transcript onward", "assembly dictate -o text | assembly llm -f"), ] @@ -47,15 +52,33 @@ def dictate( None, "--language", help="ISO 639-1 language code, or a comma-separated list for " - "code-switching audio (default: en)", + "code-switching audio (default: no language steering)", ), prompt: str | None = typer.Option( None, "--prompt", - help="Custom transcription prompt (overrides --language)", + help="Context for the transcription: what the audio is about (e.g. " + '"A doctor dictating a visit note")', + ), + keyterm: list[str] | None = typer.Option( + None, "--keyterm", help="Bias recognition toward a term, such as a name (repeatable)" ), word_boost: list[str] | None = typer.Option( - None, "--word-boost", help="Bias recognition toward a term (repeatable)" + None, "--word-boost", hidden=True, help="Deprecated: use --keyterm" + ), + instruction: str | None = typer.Option( + None, + "--instruction", + help="Describe the shape you want the text in; replaces the default cleanup", + ), + rewrite_mode: choices.RewriteMode | None = typer.Option( + None, + "--rewrite-mode", + help="Rewrite model tier: fast (default, lowest latency) or smart " + "(follows detailed instructions more reliably)", + ), + verbatim: bool = typer.Option( + False, "--verbatim", help="Print the verbatim transcript instead of the cleaned-up text" ), device: int | None = typer.Option(None, "--device", help="Microphone device index"), once: bool = typer.Option( @@ -84,18 +107,24 @@ def dictate( Recording starts immediately and runs headless — no terminal needed — so a hotkey tool like Hammerspoon can launch it as a background task and send SIGTERM (kill -TERM, task:terminate()) to stop. On SIGTERM the utterance is - sent to the AssemblyAI Sync API, the transcript prints right away (no - polling), and dictate exits, so it flows straight to the next command in a - pipe. The recording can be up to 120 seconds long. Ctrl-C (SIGINT) cancels - without transcribing. + sent to the AssemblyAI Dictation API, which returns send-ready text — filler + removed, self-corrections resolved, punctuation applied — that prints right + away, and dictate exits, so it flows straight to the next command in a pipe. + --instruction asks for a different shape; --verbatim prints the unedited + transcript. The recording can be up to 120 seconds long. Ctrl-C (SIGINT) + cancels without transcribing. """ opts = dictate_exec.DictateOptions( language=language, prompt=prompt, - word_boost=word_boost, + keyterms=keyterm, device=device, once=once, max_seconds=max_seconds, output_field=output_field, + instruction=instruction, + rewrite_mode=rewrite_mode, + verbatim=verbatim, + word_boost=word_boost, ) run_with_options(ctx, dictate_exec.run_dictate, opts, json=json_out) diff --git a/aai_cli/commands/dictate/_exec.py b/aai_cli/commands/dictate/_exec.py index 1df0f1e0..aeaa330d 100644 --- a/aai_cli/commands/dictate/_exec.py +++ b/aai_cli/commands/dictate/_exec.py @@ -1,8 +1,9 @@ """Run logic for `assembly dictate`: the options/run split (see AGENTS.md). -Headless dictation over the Sync STT API: recording starts immediately and runs +Headless dictation over the Dictation API: recording starts immediately and runs until SIGTERM is delivered (or the duration cap), then the utterance is POSTed to -the Sync API, the transcript is printed, and dictate exits 0. There is no terminal +the Dictation API, the send-ready text (its cleanup rewrite, or the verbatim +transcript with --verbatim) is printed, and dictate exits 0. There is no terminal interaction — a controller like Hammerspoon launches `assembly dictate` as a background task and sends SIGTERM (``task:terminate()`` / ``kill -TERM``) to mean "I'm done dictating", so the transcript flows straight to the next command in a @@ -20,15 +21,15 @@ import typer from aai_cli.app.context import AppState -from aai_cli.core import choices, errors, sync_stt +from aai_cli.core import choices, dictation, errors from aai_cli.core.config_builder import split_csv from aai_cli.core.microphone import MicrophoneSource from aai_cli.core.signals import stop_on_terminate from aai_cli.streaming.validate import resolve_output_modes from aai_cli.ui import output -# Capture is resampled to one rate the Sync API accepts; 16 kHz mono PCM16 keeps -# a 120 s utterance well under the 40 MB upload cap. +# Capture is resampled to one rate, sent as raw 16 kHz mono PCM16 (the live endpoint +# takes WAV or raw PCM; a 120 s utterance is ~3.8 MB, far under the 256 MB cap). TARGET_RATE = 16000 _BYTES_PER_SECOND = TARGET_RATE * 2 # PCM16 mono @@ -40,7 +41,7 @@ class DictateOptions: language: str | None prompt: str | None - word_boost: list[str] | None + keyterms: list[str] | None device: int | None # Deprecated no-op: recording one utterance and exiting is now the default, # so --once is kept only so existing scripts don't break (it warns + does @@ -49,6 +50,13 @@ class DictateOptions: max_seconds: float # -o/--output: text (the default bare-transcript shape) or json (== --json). output_field: choices.TextOrJson | None = None + # The rewrite: a custom instruction (replaces the default cleanup), its tier, + # and whether to print the verbatim transcript instead of the rewrite. + instruction: str | None = None + rewrite_mode: choices.RewriteMode | None = None + verbatim: bool = False + # Deprecated --word-boost terms (a hidden alias of --keyterm that warns). + word_boost: list[str] | None = None def _note(message: str, *, json_mode: bool, quiet: bool) -> None: @@ -59,13 +67,18 @@ def _note(message: str, *, json_mode: bool, quiet: bool) -> None: output.error_console.print(f"[aai.muted]{message}[/aai.muted]") -def _languages(language: str | None) -> str | list[str] | None: - """Fold --language into the config shape: one ISO code as a string, a - comma-separated list (code-switching audio) as a list, blank as unset.""" - codes = split_csv(language) - if not codes: - return None - return codes[0] if len(codes) == 1 else codes +def _config(opts: DictateOptions) -> dictation.DictationConfig: + """The Dictation config from the flags: --language as ``language_codes`` (one + code, or a comma-separated list for code-switching audio), --keyterm (plus the + deprecated --word-boost) as ``keyterms_prompt``, and the rewrite knobs.""" + keyterms = [*(opts.keyterms or []), *(opts.word_boost or [])] + return dictation.DictationConfig( + language_codes=split_csv(opts.language) or None, + stt_prompt=opts.prompt, + keyterms=keyterms or None, + llm_instruction=opts.instruction, + rewrite_mode=opts.rewrite_mode, + ) def _record( @@ -96,8 +109,9 @@ def _record( return bytes(pcm) -def _emit(result: sync_stt.SyncTranscript, *, json_mode: bool) -> None: - """One utterance to stdout: the bare transcript text, or one NDJSON object.""" +def _emit(result: dictation.DictationResult, *, verbatim: bool, json_mode: bool) -> None: + """One utterance to stdout: the send-ready text (or the verbatim transcript), or + one NDJSON object carrying both.""" if json_mode: # "type" first: every multi-line NDJSON stream the CLI emits discriminates # its lines the same way (stream/agent already do; see docs/cli-reference.md). @@ -105,13 +119,25 @@ def _emit(result: sync_stt.SyncTranscript, *, json_mode: bool) -> None: { "type": "utterance", "text": result.text, + "llm_response": result.llm_response, + "llm_error": result.llm_error, "confidence": result.confidence, "audio_duration_ms": result.audio_duration_ms, "session_id": result.session_id, } ) - else: + return + if verbatim: output.emit_text(result.text) + return + if result.llm_error is not None: + # The rewrite is best-effort and never fails the call: say why the verbatim + # transcript is printed instead of quietly handing it on. + output.emit_warning( + f"The cleanup rewrite failed ({result.llm_error}); printing the verbatim transcript.", + json_mode=json_mode, + ) + output.emit_text(result.final_text) def _transcribe_utterance( @@ -122,27 +148,19 @@ def _transcribe_utterance( *, json_mode: bool, ) -> None: - """Send one recorded utterance to the Sync API and print the transcript. + """Send one recorded utterance to the Dictation API and print the result. - A recording below the API's 80 ms floor (an instant SIGTERM) is skipped with a - warning rather than bounced off the server as a 400. + An empty recording (an instant SIGTERM) is skipped with a warning rather than + bounced off the server, which refuses an empty audio part with a 400. """ - if len(pcm) < sync_stt.MIN_AUDIO_MS * _BYTES_PER_SECOND // 1000: - output.emit_warning( - f"Recording was shorter than {sync_stt.MIN_AUDIO_MS} ms; nothing to transcribe.", - json_mode=json_mode, - ) + if not pcm: + output.emit_warning("Nothing was recorded; nothing to transcribe.", json_mode=json_mode) return with output.status("Transcribing…", json_mode=json_mode, quiet=state.quiet): - result = sync_stt.transcribe_pcm( - api_key, - pcm, - sample_rate=TARGET_RATE, - language_code=_languages(opts.language), - prompt=opts.prompt, - word_boost=opts.word_boost, + result = dictation.transcribe_live( + api_key, pcm, sample_rate=TARGET_RATE, config=_config(opts) ) - _emit(result, json_mode=json_mode) + _emit(result, verbatim=opts.verbatim, json_mode=json_mode) def _capture_and_transcribe( @@ -176,13 +194,9 @@ def run_dictate(opts: DictateOptions, state: AppState, *, json_mode: bool) -> No _, json_mode = resolve_output_modes(opts.output_field, json_mode=json_mode) # Resolve credentials before recording: don't capture audio we can't transcribe. api_key = state.resolve_api_key() - if opts.prompt and opts.language: - # The server ignores language_code whenever a custom prompt is set; - # never drop a requested flag silently (mirrors the speak warnings). - output.emit_warning( - "--language is ignored when --prompt is set; state the language inside the prompt.", - json_mode=json_mode, - ) + if opts.word_boost and not state.quiet: + # Deprecation trap, not removal: the Dictation API calls these keyterms. + output.emit_warning("--word-boost is deprecated; use --keyterm.", json_mode=json_mode) if opts.once and not state.quiet: # Deprecation trap, not removal: --once still parses so old scripts don't # break, but recording one utterance and exiting is now the default, so the diff --git a/aai_cli/commands/dub/__init__.py b/aai_cli/commands/dub/__init__.py index fe4e2a54..a7b585ee 100644 --- a/aai_cli/commands/dub/__init__.py +++ b/aai_cli/commands/dub/__init__.py @@ -21,41 +21,40 @@ @app.command( rich_help_panel=help_panels.TRANSCRIPTION, - # --sandbox is a root flag, so it must come before the subcommand in every example. epilog=examples_epilog( [ - ("Dub a talk into German (sandbox only)", "assembly --sandbox dub talk.mp4 --lang de"), - ("Use a language name instead of a code", "assembly --sandbox dub talk.mp4 -l Spanish"), + ("Dub a talk into German", "assembly dub talk.mp4 --lang de"), + ("Use a language name instead of a code", "assembly dub talk.mp4 -l Spanish"), ( "Dub the full video from YouTube", - 'assembly --sandbox dub "https://youtube.com/watch?v=ID" -l de --video', + 'assembly dub "https://youtube.com/watch?v=ID" -l de --video', ), ( "Dub only the first 15 minutes of a YouTube video", - 'assembly --sandbox dub "https://youtube.com/watch?v=ID" -l de --video ' + 'assembly dub "https://youtube.com/watch?v=ID" -l de --video ' '--download-sections "*0:00-15:00"', ), ( "Dub every speaker with one voice", - "assembly --sandbox dub talk.mp4 -l fr --voice paul", + "assembly dub talk.mp4 -l fr --voice paul", ), ( "Pin a voice per diarized speaker", - "assembly --sandbox dub panel.mp4 -l de --voice A=jane --voice B=paul", + "assembly dub panel.mp4 -l de --voice A=jane --voice B=paul", ), ( "Reuse a finished transcript instead of re-transcribing", - "assembly --sandbox dub talk.mp4 -l de -t TRANSCRIPT_ID", + "assembly dub talk.mp4 -l de -t TRANSCRIPT_ID", ), ( "Choose the output file", - "assembly --sandbox dub talk.mp4 -l de --out talk-german.mp4", + "assembly dub talk.mp4 -l de --out talk-german.mp4", ), ( "Dub a whole folder into German", - 'find talks -name "*.mp4" | assembly --sandbox dub --from-stdin -l de', + 'find talks -name "*.mp4" | assembly dub --from-stdin -l de', ), - ("See the available voices", "assembly --sandbox dub --list-voices"), + ("See the available voices", "assembly dub --list-voices"), ] ), ) @@ -97,7 +96,7 @@ def dub( "--voice", help="Voice for every speaker (e.g. jane, michael, paul; see --list-voices), or " "SPEAKER=VOICE to pin a diarized speaker (repeatable, e.g. --voice A=jane). " - "Default: the target language's native voice(s).", + "Default: speakers rotate through the built-in voices, which speak every language.", ), model: str = typer.Option( llm.DEFAULT_MODEL, @@ -132,7 +131,7 @@ def dub( _list_voices: bool = options.list_voices_option("dub"), json_out: bool = options.json_option("Emit JSON describing the dubbed file"), ) -> None: - r"""\[sandbox] Dub a video or audio file into another language + r"""Dub a video or audio file into another language The whole platform in one command: the media is transcribed with diarized utterance timestamps, each utterance is translated by an LLM Gateway model, @@ -140,9 +139,7 @@ def dub( speaker), and ffmpeg lays the new audio over the original — video copied untouched. A YouTube/media-page URL is downloaded first (audio only, or the full video with --video; --download-sections fetches and dubs only a - time slice of it). Streaming TTS only exists in the sandbox - today — run it as 'assembly --sandbox dub' (--sandbox goes before the - subcommand). Requires ffmpeg. + time slice of it). Requires ffmpeg. Batch mode: pipe one path/URL per line with --from-stdin to dub many sources concurrently (--concurrency). Each writes its own .dub.; a diff --git a/aai_cli/commands/dub/_exec.py b/aai_cli/commands/dub/_exec.py index 153c7ecf..efcc3bdb 100644 --- a/aai_cli/commands/dub/_exec.py +++ b/aai_cli/commands/dub/_exec.py @@ -253,7 +253,7 @@ def _dub_build( translations = pipeline.translate( api_key, utterances, language, opts, json_mode=json_mode, quiet=state.quiet ) - resolved, speakers = pipeline.assign_voices(utterances, translations, voice_plan, language) + resolved, speakers = pipeline.assign_voices(utterances, translations, voice_plan) pipeline.warn_ignored_voice_pins(voice_plan.overrides, speakers, json_mode=json_mode) pcm_segments, sample_rate = pipeline.synthesize( api_key, resolved, language, json_mode=json_mode, quiet=state.quiet diff --git a/aai_cli/commands/dub/_pipeline.py b/aai_cli/commands/dub/_pipeline.py index 9f24335e..86ea7de7 100644 --- a/aai_cli/commands/dub/_pipeline.py +++ b/aai_cli/commands/dub/_pipeline.py @@ -240,17 +240,15 @@ def assign_voices( utterances: list[Utterance], translations: list[str], plan: VoicePlan, - language: str, ) -> tuple[list[tuple[str, str]], dict[str, str]]: """Resolve each translated utterance to ``(voice, text)`` plus the speaker→voice map. A bare ``--voice`` dubs every speaker with that one voice; ``SPEAKER=VOICE`` - mappings pin individual speakers; everyone else takes the target language's - rotation in first-appearance order (the same rules as `assembly speak`) — - each voice speaks one language, so a non-English dub switches to that - language's native voice(s). + mappings pin individual speakers; everyone else takes the default rotation + in first-appearance order (the same rules as `assembly speak`) — every + built-in voice speaks the target language, so the rotation never changes. """ - rotation = (plan.bare,) if plan.bare is not None else voices.rotation_for(language) + rotation = (plan.bare,) if plan.bare is not None else voices.DEFAULT_ROTATION segments = [ dialogue.Segment(utterance.speaker, translated) # strict=True is an invariant guard only: translate returns exactly one diff --git a/aai_cli/commands/speak/__init__.py b/aai_cli/commands/speak/__init__.py index 4881778a..4b901744 100644 --- a/aai_cli/commands/speak/__init__.py +++ b/aai_cli/commands/speak/__init__.py @@ -21,31 +21,30 @@ @app.command( rich_help_panel=help_panels.TRANSCRIPTION, - # --sandbox is a root flag, so it must come before the subcommand in every example. epilog=examples_epilog( [ - ("Speak text aloud (sandbox only)", 'assembly --sandbox speak "Hello there, friend."'), + ("Speak text aloud", 'assembly speak "Hello there, friend."'), ( - "Pick a language (its native voice is selected automatically)", - 'assembly --sandbox speak "Bonjour" --language French', + "Speak another language (every built-in voice speaks it)", + 'assembly speak "Bonjour" --language French', ), ( "Speak a diarized transcript, one voice per speaker", - "assembly transcribe meeting.mp3 --speaker-labels | assembly --sandbox speak", + "assembly transcribe meeting.mp3 --speaker-labels | assembly speak", ), ( "Override a speaker's voice", - "… | assembly --sandbox speak --voice A=vera --voice B=paul", + "… | assembly speak --voice A=vera --voice B=paul", ), ( "Read a web page aloud (boilerplate stripped)", - "assembly --sandbox speak --url https://example.com/post", + "assembly speak --url https://example.com/post", ), ( "Save to a WAV instead of playing", - 'assembly --sandbox speak "Hello" --out /tmp/hello.wav', + 'assembly speak "Hello" --out /tmp/hello.wav', ), - ("See the available voices", "assembly --sandbox speak --list-voices"), + ("See the available voices", "assembly speak --list-voices"), ] ), ) @@ -67,8 +66,8 @@ def speak( language: str = typer.Option( DEFAULT_LANGUAGE, "--language", - help="Language of the text. The default voice follows it " - "(e.g. --language Italian speaks with giovanni).", + help="Language of the text: English, Spanish, German, French, Italian, or " + "Portuguese (a name or ISO code). Every built-in voice speaks all six.", ), sample_rate: int | None = typer.Option( None, @@ -83,15 +82,14 @@ def speak( _list_voices: bool = options.list_voices_option("speak"), json_out: bool = options.json_option("Emit JSON metadata about the synthesized audio"), ) -> None: - r"""\[sandbox] Synthesize speech from text with AssemblyAI streaming TTS + r"""Synthesize speech from text with AssemblyAI streaming TTS Reads text from the argument, piped stdin, or a web page or PDF with --url (its main content is extracted and the boilerplate stripped). Plays the audio through your speakers by default, or writes a WAV with --out. Speaker-labeled input (from 'assembly transcribe --speaker-labels') is detected automatically: the labels are stripped and each speaker gets a - different voice. This feature only exists in the sandbox today — run it - as 'assembly --sandbox speak' (--sandbox goes before the subcommand). + different voice. """ opts = speak_exec.SpeakOptions( diff --git a/aai_cli/commands/speak/_exec.py b/aai_cli/commands/speak/_exec.py index 02c43d11..ef815c35 100644 --- a/aai_cli/commands/speak/_exec.py +++ b/aai_cli/commands/speak/_exec.py @@ -17,10 +17,8 @@ from aai_cli.tts import audio, dialogue, session, voices from aai_cli.ui import output -# The streaming-TTS reference client defaults to English, so the CLI does the -# same. The default voice follows the language (voices.default_voice): each -# voice speaks one language, so e.g. --language Italian switches to giovanni -# unless --voice overrides it. +# The streaming-TTS service defaults to English, so the CLI does the same. Every +# built-in voice speaks every language, so --language never changes the voice. DEFAULT_LANGUAGE = "English" @@ -188,9 +186,7 @@ def _speak_dialogue( "set a voice per speaker with --voice A=NAME.", json_mode=json_mode, ) - resolved, speakers = dialogue.assign_voices( - segments, voices.rotation_for(opts.language), overrides - ) + resolved, speakers = dialogue.assign_voices(segments, voices.DEFAULT_ROTATION, overrides) with output.status("Synthesizing speech…", json_mode=json_mode, quiet=quiet): result = session.synthesize_dialogue( api_key, @@ -233,7 +229,7 @@ def run_speak(opts: SpeakOptions, state: AppState, *, json_mode: bool) -> None: _speak_single( api_key, spoken, - bare_voice or voices.default_voice(opts.language), + bare_voice or voices.DEFAULT_VOICE, opts, json_mode=json_mode, quiet=state.quiet, diff --git a/aai_cli/core/choices.py b/aai_cli/core/choices.py index 5281d646..ef08d546 100644 --- a/aai_cli/core/choices.py +++ b/aai_cli/core/choices.py @@ -50,6 +50,13 @@ class TextOrJson(enum.StrEnum): json = "json" +class RewriteMode(enum.StrEnum): + """The Dictation API's rewrite tiers (`dictate --rewrite-mode`).""" + + fast = "fast" + smart = "smart" + + class Scope(enum.StrEnum): """Coding-agent config scope for `assembly setup` (passed through to `claude mcp add`).""" diff --git a/aai_cli/core/dictation.py b/aai_cli/core/dictation.py new file mode 100644 index 00000000..e0f1f50f --- /dev/null +++ b/aai_cli/core/dictation.py @@ -0,0 +1,163 @@ +"""HTTP boundary for the Dictation API (dictation.assemblyai.com). + +One POST per utterance to ``/v1/transcribe/live`` (/docs/dictation): a multipart +body whose JSON ``config`` part comes first and whose ``audio`` part (raw 16-bit +PCM, at most 120 s) follows, and the result back in the response — the verbatim +transcript in ``text`` and the send-ready cleanup rewrite in ``llm_response``. +Backs `assembly dictate`. Like client.py/ams.py, this module normalizes every +failure into the CLIError hierarchy and stays Rich-free (import-linter contract 3). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +import httpx2 as httpx + +from aai_cli.core import environments, jsonshape +from aai_cli.core.errors import APIError, auth_failure + +# The live endpoint's documented cap on one utterance. +MAX_AUDIO_SECONDS = 120 +# Generous enough for a full 120 s upload plus transcription and the rewrite. +_TIMEOUT = 90.0 +_RETRY_STATUSES = (429, 502, 503, 504) +_HTTP_ERROR_MIN_STATUS = 400 + + +@dataclass(frozen=True) +class DictationResult: + """The fields of a Dictation response the CLI renders.""" + + text: str + llm_response: str | None + llm_error: str | None + confidence: float | None + audio_duration_ms: int | None + session_id: str | None + + @property + def final_text(self) -> str: + """The rewrite, falling back to the verbatim transcript when it failed.""" + return self.llm_response if self.llm_response is not None else self.text + + +# Import-time line: coverage credits whichever test imported this module first, so the +# mutation gate can't route it to test_config_is_immutable, which pins it. +@dataclass(frozen=True) # pragma: no mutate +class DictationConfig: + """The optional transcription and rewrite knobs; unset ones are left out.""" + + language_codes: list[str] | None = None + stt_prompt: str | None = None + keyterms: list[str] | None = None + llm_instruction: str | None = None + rewrite_mode: str | None = None + + def body(self, *, sample_rate: int, channels: int) -> dict[str, object]: + """The JSON ``config`` part: PCM geometry always, the knobs only when set.""" + cfg: dict[str, object] = {"sample_rate": sample_rate, "channels": channels} + if self.language_codes: + cfg["language_codes"] = list(self.language_codes) + if self.stt_prompt: + cfg["stt_prompt"] = self.stt_prompt + if self.keyterms: + cfg["keyterms_prompt"] = list(self.keyterms) + if self.llm_instruction: + cfg["llm_instruction"] = self.llm_instruction + if self.rewrite_mode: + cfg["rewrite_mode"] = self.rewrite_mode + return cfg + + +def _detail(resp: httpx.Response) -> str: + """A human-readable failure detail. Errors come in two shapes — ``detail`` (auth, + rate limits, transcription) or ``error`` + ``error_code`` (Dictation's own) — so + read ``detail`` first, then ``error``, else the raw body or the bare status.""" + fallback = resp.text or f"HTTP {resp.status_code}" + try: + body: object = resp.json() + except ValueError: + return fallback + mapping = jsonshape.as_mapping(body) + if mapping is None: + return fallback + message = mapping.get("detail") or mapping.get("error") + if message is None: + return fallback + code = mapping.get("error_code") + return f"{message} ({code})" if code else str(message) + + +def _raise_for_error(resp: httpx.Response) -> None: + if resp.status_code in (401, 403): + raise auth_failure() + if resp.status_code in _RETRY_STATUSES: + # Rate limit, capacity, and upstream failures/timeouts: the documented + # recovery is to back off and retry. + raise APIError( + f"The Dictation API is busy ({resp.status_code}): {_detail(resp)}", + suggestion="Wait a moment and try again.", + ) + if resp.status_code >= _HTTP_ERROR_MIN_STATUS: + raise APIError(f"Dictation failed ({resp.status_code}): {_detail(resp)}") + + +def _optional_str(value: object) -> str | None: + return str(value) if value is not None else None + + +def _parse(resp: httpx.Response) -> DictationResult: + try: + body: object = resp.json() + except ValueError as exc: + raise APIError( + f"The Dictation API returned a response that is not valid JSON " + f"(HTTP {resp.status_code})." + ) from exc + mapping = jsonshape.as_mapping(body) + if mapping is None or "text" not in mapping: + raise APIError("The Dictation API returned an unexpected response shape (no text).") + confidence = mapping.get("confidence") + duration = mapping.get("audio_duration_ms") + return DictationResult( + text=str(mapping["text"]), + llm_response=_optional_str(mapping.get("llm_response")), + llm_error=_optional_str(mapping.get("llm_error")), + confidence=jsonshape.as_float(confidence) if confidence is not None else None, + audio_duration_ms=jsonshape.as_int(duration) if duration is not None else None, + session_id=_optional_str(mapping.get("session_id")), + ) + + +def transcribe_live( + api_key: str, + pcm: bytes, + *, + sample_rate: int, + channels: int = 1, + config: DictationConfig | None = None, +) -> DictationResult: + """POST raw PCM (S16LE) to ``/v1/transcribe/live`` and return the result.""" + cfg = (config or DictationConfig()).body(sample_rate=sample_rate, channels=channels) + # Order matters: the server starts transcribing as the body arrives and can't + # without the config, so `config` must precede `audio` (else a 400). + files: dict[str, tuple[str | None, bytes | str, str | None]] = { + "config": (None, json.dumps(cfg), "application/json"), + "audio": ("audio.pcm", pcm, "audio/pcm"), + } + try: + with httpx.Client(timeout=_TIMEOUT) as client: + resp = client.post( + f"{environments.active().dictation_base}/v1/transcribe/live", + headers={"authorization": api_key}, + files=files, + ) + except httpx.HTTPError as exc: + raise APIError( + f"Could not reach the Dictation API: {exc}", + suggestion="Check your network connection and try again.", + ) from exc + _raise_for_error(resp) + return _parse(resp) diff --git a/aai_cli/core/environments.py b/aai_cli/core/environments.py index a9f750c8..fb4e081d 100644 --- a/aai_cli/core/environments.py +++ b/aai_cli/core/environments.py @@ -16,9 +16,9 @@ class Environment: name: str api_base: str # SDK base_url for /v2/upload + /v2/transcript - sync_base: str # Sync STT API base (one-shot POST /transcribe, used by `assembly dictate`) + dictation_base: str # Dictation API base (POST /v1/transcribe/live, used by `assembly dictate`) streaming_host: str # StreamingClientOptions.api_host (SDK builds wss://host/v3/ws) - streaming_tts_host: str # streaming TTS host; empty when TTS isn't available (prod) + streaming_tts_host: str # streaming TTS host; empty when an env has no TTS agents_host: str # Voice Agent host; the agent client builds wss://host/v1/ws llm_gateway_base: str # OpenAI base_url for the LLM Gateway (…/v1) ams_base: str # Accounts Management Service @@ -38,9 +38,9 @@ class Environment: "production": Environment( name="production", api_base="https://api.assemblyai.com", - sync_base="https://sync.assemblyai.com", + dictation_base="https://dictation.assemblyai.com", streaming_host="streaming.assemblyai.com", - streaming_tts_host="", + streaming_tts_host="tts.us.assemblyai.com", agents_host="agents.assemblyai.com", llm_gateway_base="https://llm-gateway.assemblyai.com/v1", ams_base="https://ams.internal.assemblyai-labs.com", @@ -51,7 +51,7 @@ class Environment: "sandbox000": Environment( name="sandbox000", api_base="https://api.sandbox000.assemblyai-labs.com", - sync_base="https://sync.sandbox000.assemblyai-labs.com", + dictation_base="https://dictation.sandbox000.assemblyai-labs.com", streaming_host="streaming.sandbox000.assemblyai-labs.com", streaming_tts_host="streaming-tts.sandbox000.assemblyai-labs.com", agents_host="agents.sandbox000.assemblyai-labs.com", diff --git a/aai_cli/core/llm.py b/aai_cli/core/llm.py index f3009a01..5daf33c4 100644 --- a/aai_cli/core/llm.py +++ b/aai_cli/core/llm.py @@ -47,13 +47,12 @@ "gpt-oss-20b", # Google "gemini-3.5-flash", - "gemini-3-flash-preview", - "gemini-3.1-flash-lite-preview", + "gemini-3.1-flash-lite", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", # Moonshot AI - "kimi-k2.5", + "kimi-k3", # Alibaba Cloud "qwen3-next-80b-a3b", "qwen3-32B", diff --git a/aai_cli/core/sync_stt.py b/aai_cli/core/sync_stt.py deleted file mode 100644 index 475706be..00000000 --- a/aai_cli/core/sync_stt.py +++ /dev/null @@ -1,141 +0,0 @@ -"""HTTP boundary for the Sync STT API (sync.assemblyai.com). - -One POST per utterance — multipart audio + JSON config in, the transcript back in -the response body. No polling, no session management; the Universal-3 Pro sync -model handles audio from 80 ms up to 120 s. Backs `assembly dictate`. Like -client.py/ams.py, this module normalizes every failure into the CLIError -hierarchy and stays Rich-free (import-linter contract 3). -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass - -import httpx2 as httpx - -from aai_cli.core import environments, jsonshape -from aai_cli.core.errors import APIError, auth_failure - -# The X-AAI-Model header is required on every Sync API request. -SYNC_MODEL = "u3-sync-pro" -# Documented audio constraints; callers gate on these before uploading. -MIN_AUDIO_MS = 80 -MAX_AUDIO_SECONDS = 120 -# The request itself has a 30 s inference deadline server-side; leave headroom -# for the upload of up to 40 MB of audio. -_TIMEOUT = 60.0 -_RATE_LIMIT_STATUSES = (429, 503) -_HTTP_ERROR_MIN_STATUS = 400 - - -@dataclass(frozen=True) -class SyncTranscript: - """The fields of a Sync API response the CLI renders.""" - - text: str - confidence: float | None - audio_duration_ms: int | None - session_id: str | None - - -def _detail(resp: httpx.Response) -> str: - """A human-readable failure detail: the body's message/detail (with its - error_code when present), else the raw body, else the bare HTTP status.""" - fallback = resp.text or f"HTTP {resp.status_code}" - try: - body: object = resp.json() - except ValueError: - return fallback - mapping = jsonshape.as_mapping(body) - if mapping is None: - return fallback - message = mapping.get("message") or mapping.get("detail") - if message is None: - return fallback - code = mapping.get("error_code") - return f"{message} ({code})" if code else str(message) - - -def _raise_for_error(resp: httpx.Response) -> None: - if resp.status_code in (401, 403): - raise auth_failure() - if resp.status_code in _RATE_LIMIT_STATUSES: - # 429 (rate limit) and 503 (capacity / model cold start) are both - # transient: the documented recovery is simply to retry shortly. - raise APIError( - f"The Sync API is busy ({resp.status_code}): {_detail(resp)}", - suggestion="Wait a moment and try again.", - ) - if resp.status_code >= _HTTP_ERROR_MIN_STATUS: - raise APIError(f"Sync transcription failed ({resp.status_code}): {_detail(resp)}") - - -def _parse(resp: httpx.Response) -> SyncTranscript: - try: - body: object = resp.json() - except ValueError as exc: - raise APIError( - f"The Sync API returned a response that is not valid JSON (HTTP {resp.status_code})." - ) from exc - mapping = jsonshape.as_mapping(body) - if mapping is None or "text" not in mapping: - raise APIError("The Sync API returned an unexpected response shape (no transcript text).") - confidence = mapping.get("confidence") - duration = mapping.get("audio_duration_ms") - session_id = mapping.get("session_id") - return SyncTranscript( - text=str(mapping["text"]), - confidence=jsonshape.as_float(confidence) if confidence is not None else None, - audio_duration_ms=jsonshape.as_int(duration) if duration is not None else None, - session_id=str(session_id) if session_id is not None else None, - ) - - -def _config( - sample_rate: int, - channels: int, - language_code: str | list[str] | None, - prompt: str | None, - word_boost: list[str] | None, -) -> dict[str, object]: - """The JSON config part: PCM geometry always, model knobs only when set.""" - cfg: dict[str, object] = {"sample_rate": sample_rate, "channels": channels} - if language_code: - cfg["language_code"] = language_code - if prompt: - cfg["prompt"] = prompt - if word_boost: - cfg["word_boost"] = list(word_boost) - return cfg - - -def transcribe_pcm( - api_key: str, - pcm: bytes, - *, - sample_rate: int, - channels: int = 1, - language_code: str | list[str] | None = None, - prompt: str | None = None, - word_boost: list[str] | None = None, -) -> SyncTranscript: - """POST raw PCM (S16LE) to the Sync API and return the finished transcript.""" - cfg = _config(sample_rate, channels, language_code, prompt, word_boost) - files: dict[str, tuple[str | None, bytes | str, str | None]] = { - "audio": ("audio.pcm", pcm, "audio/pcm"), - "config": (None, json.dumps(cfg), "application/json"), - } - headers = {"authorization": api_key, "x-aai-model": SYNC_MODEL} - try: - with httpx.Client(timeout=_TIMEOUT) as client: - resp = client.post( - f"{environments.active().sync_base}/transcribe", headers=headers, files=files - ) - except httpx.HTTPError as exc: - raise APIError( - f"Could not reach the Sync API: {exc}", - suggestion="Check your network connection and try again.", - ) from exc - _raise_for_error(resp) - return _parse(resp) diff --git a/aai_cli/init/templates/__init__.py b/aai_cli/init/templates/__init__.py index ee336ac2..cafbd254 100644 --- a/aai_cli/init/templates/__init__.py +++ b/aai_cli/init/templates/__init__.py @@ -27,7 +27,7 @@ "audio-transcription": "Transcribe audio & video files, URLs, and YouTube — speaker labels and audio intelligence", "live-captions": "Live real-time captions from your microphone over the Streaming API", "voice-agent": "Full-duplex voice agent (speech in, LLM reply, speech out) via the Voice Agent API", - "agent-cascade": "Cascaded voice agent you orchestrate: Streaming STT, the LLM Gateway, and sandbox TTS", + "agent-cascade": "Cascaded voice agent you orchestrate: Streaming STT, the LLM Gateway, and streaming TTS", } diff --git a/aai_cli/init/templates/agent_cascade/README.md b/aai_cli/init/templates/agent_cascade/README.md index 40263bc4..943975f5 100644 --- a/aai_cli/init/templates/agent_cascade/README.md +++ b/aai_cli/init/templates/agent_cascade/README.md @@ -6,17 +6,6 @@ Streaming STT transcribes you, the LLM Gateway generates a reply, and streaming speaks it back — with turn detection and barge-in handled server-side. The browser holds one WebSocket to your backend, so your API key never reaches the client. -## Sandbox-only - -Streaming TTS has no production host, so the whole cascade runs against the AssemblyAI -sandbox with a sandbox key. Scaffold it that way: - -```sh -assembly --sandbox init agent-cascade -``` - -That pins the sandbox hosts in `.env`. Running against production exits with a hint. - ## Run locally ```sh @@ -35,8 +24,9 @@ Railway, Fly.io, or Google Cloud Run (`gcloud run deploy --source .`): uvicorn api.index:app --host 0.0.0.0 --port $PORT ``` -Set `ASSEMBLYAI_API_KEY` and the three sandbox host vars (`ASSEMBLYAI_STREAMING_HOST`, -`ASSEMBLYAI_TTS_HOST`, `ASSEMBLYAI_LLM_GATEWAY_URL`) in the platform's environment. +Set `ASSEMBLYAI_API_KEY` in the platform's environment. The three host vars +(`ASSEMBLYAI_STREAMING_HOST`, `ASSEMBLYAI_TTS_HOST`, `ASSEMBLYAI_LLM_GATEWAY_URL`) +default to production; set them only to target another environment. ## Ideas to extend diff --git a/aai_cli/init/templates/agent_cascade/api/cascade.py b/aai_cli/init/templates/agent_cascade/api/cascade.py index 6c96aac3..f220d07b 100644 --- a/aai_cli/init/templates/agent_cascade/api/cascade.py +++ b/aai_cli/init/templates/agent_cascade/api/cascade.py @@ -55,15 +55,15 @@ async def recv(self) -> dict[str, object] | None: def unavailable_reason(settings: _Settings) -> str | None: """Why the cascade can't run, or None when it can. - Streaming TTS has no production host, so an empty TTS host means the user must - re-scaffold against the sandbox. + An empty TTS host means the deployment's environment cleared it: the cascade + can't speak without one. """ if not settings.API_KEY: return "ASSEMBLYAI_API_KEY is not set — configure it in your deployment's environment." if not settings.TTS_HOST: return ( - "Streaming TTS has no production host, so this cascade is sandbox-only. " - "Re-scaffold against the sandbox: assembly --sandbox init agent-cascade." + "ASSEMBLYAI_TTS_HOST is empty — set it to your streaming-TTS host " + "(tts.us.assemblyai.com in production)." ) return None @@ -83,8 +83,14 @@ def stt_url(settings: _Settings) -> str: def tts_url(settings: _Settings) -> str: """The streaming-TTS WebSocket URL for the configured voice and sample rate.""" - params = urlencode({"voice_id": settings.VOICE_ID, "sample_rate": settings.OUTPUT_SAMPLE_RATE}) - return f"wss://{settings.TTS_HOST}/v1/ws/?{params}" + params = urlencode( + { + "voice_id": settings.VOICE_ID, + "sample_rate": settings.OUTPUT_SAMPLE_RATE, + "encoding": "pcm_s16le", + } + ) + return f"wss://{settings.TTS_HOST}/v1/ws?{params}" def is_final_user_turn(msg: dict[str, object]) -> bool: @@ -225,15 +231,16 @@ async def _synthesize(browser: _Browser, tts: ClientConnection, text: str) -> No raise RuntimeError(f"TTS did not begin (got {begin.get('type')!r}).") await tts.send(json.dumps({"type": "Generate", "text": text})) await tts.send(json.dumps({"type": "Flush"})) - # Iterate the socket (like _pump_stt) so a close before the final Audio frame ends - # the loop cleanly instead of raising ConnectionClosed out of the reply. + # Iterate the socket (like _pump_stt) so an early close ends the loop cleanly instead + # of raising ConnectionClosed out of the reply. FlushDone marks the request's last + # Audio frame delivered; the socket itself stays open until Terminate. async for raw in tts: frame = json.loads(raw) kind = frame.get("type") if kind == "Audio": await browser.send({"type": "reply.audio", "data": frame.get("audio", "")}) - if frame.get("is_final"): - break + elif kind == "FlushDone": + break elif kind == "Error": raise RuntimeError(frame.get("error") or "TTS error") with contextlib.suppress(Exception): diff --git a/aai_cli/init/templates/agent_cascade/api/index.py b/aai_cli/init/templates/agent_cascade/api/index.py index f72f52c2..17b36109 100644 --- a/aai_cli/init/templates/agent_cascade/api/index.py +++ b/aai_cli/init/templates/agent_cascade/api/index.py @@ -2,8 +2,7 @@ The browser opens one WebSocket to this backend, which runs the cascade itself — Streaming STT -> LLM Gateway -> streaming TTS — so your API key never reaches the -client. Streaming TTS is sandbox-only, so scaffold with `assembly --sandbox init -agent-cascade` and use a sandbox key. +client. WS /ws <- {"type":"input.audio","audio":} ; -> transcripts + reply.audio """ diff --git a/aai_cli/init/templates/agent_cascade/api/settings.py b/aai_cli/init/templates/agent_cascade/api/settings.py index 70fb819a..9226fdc6 100644 --- a/aai_cli/init/templates/agent_cascade/api/settings.py +++ b/aai_cli/init/templates/agent_cascade/api/settings.py @@ -8,17 +8,12 @@ API_KEY: str = os.environ.get("ASSEMBLYAI_API_KEY", "") -# Hosts. `assembly init` pins these to the active environment. Streaming TTS only -# exists in the sandbox, so this whole cascade is sandbox-only (see README); the -# defaults point at the sandbox so a bare clone works with a sandbox key. -STREAMING_HOST: str = os.environ.get( - "ASSEMBLYAI_STREAMING_HOST", "streaming.sandbox000.assemblyai-labs.com" -) -TTS_HOST: str = os.environ.get( - "ASSEMBLYAI_TTS_HOST", "streaming-tts.sandbox000.assemblyai-labs.com" -) +# Hosts. `assembly init` pins these to the active environment; the defaults are +# production, so a bare clone works with a production key. +STREAMING_HOST: str = os.environ.get("ASSEMBLYAI_STREAMING_HOST", "streaming.assemblyai.com") +TTS_HOST: str = os.environ.get("ASSEMBLYAI_TTS_HOST", "tts.us.assemblyai.com") LLM_GATEWAY_URL: str = os.environ.get( - "ASSEMBLYAI_LLM_GATEWAY_URL", "https://llm-gateway.sandbox000.assemblyai-labs.com/v1" + "ASSEMBLYAI_LLM_GATEWAY_URL", "https://llm-gateway.assemblyai.com/v1" ) # The cascade's knobs — edit these to change behavior. diff --git a/aai_cli/init/templates/agent_cascade/env.example b/aai_cli/init/templates/agent_cascade/env.example index fa30178a..7d9a25c6 100644 --- a/aai_cli/init/templates/agent_cascade/env.example +++ b/aai_cli/init/templates/agent_cascade/env.example @@ -1,6 +1,6 @@ ASSEMBLYAI_API_KEY=your_assemblyai_api_key_here -# This cascade uses streaming TTS, which is sandbox-only — use a sandbox key and the -# sandbox hosts (assembly --sandbox init agent-cascade fills these in for you): -# ASSEMBLYAI_STREAMING_HOST=streaming.sandbox000.assemblyai-labs.com -# ASSEMBLYAI_TTS_HOST=streaming-tts.sandbox000.assemblyai-labs.com -# ASSEMBLYAI_LLM_GATEWAY_URL=https://llm-gateway.sandbox000.assemblyai-labs.com/v1 +# Hosts default to production; override them to target another environment +# (assembly init fills these in for the active one): +# ASSEMBLYAI_STREAMING_HOST=streaming.assemblyai.com +# ASSEMBLYAI_TTS_HOST=tts.us.assemblyai.com +# ASSEMBLYAI_LLM_GATEWAY_URL=https://llm-gateway.assemblyai.com/v1 diff --git a/aai_cli/options.py b/aai_cli/options.py index f3806729..6f1072ff 100644 --- a/aai_cli/options.py +++ b/aai_cli/options.py @@ -105,9 +105,9 @@ def list_voices_option(command: str) -> bool: One factory so the three can't drift. The flag does its work in its own option callback and exits, so it needs none of the command's other input (``assembly - --sandbox dub --list-voices`` runs without the otherwise-required ``--lang``); + dub --list-voices`` runs without the otherwise-required ``--lang``); it is *eager* so that, like ``--help``, it wins even when an option given - earlier on the line is invalid. ``command`` names the command in the sandbox hint. + earlier on the line is invalid. ``command`` names the command in the no-TTS hint. """ def _callback(ctx: typer.Context, *, value: bool) -> bool: diff --git a/aai_cli/tts/session.py b/aai_cli/tts/session.py index 43049aef..f498845a 100644 --- a/aai_cli/tts/session.py +++ b/aai_cli/tts/session.py @@ -6,7 +6,7 @@ import json from abc import abstractmethod from collections.abc import Callable, Mapping -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from typing import Protocol from urllib.parse import urlencode @@ -45,6 +45,15 @@ def close(self) -> None: # Pause inserted between speaker turns in a multi-voice dialogue, for natural pacing. _INTER_TURN_SILENCE_SECONDS = 0.25 +# The audio encoding the CLI decodes (16-bit little-endian PCM), pinned on every session +# rather than left to the server default, since playback and WAV output assume it. +_ENCODING = "pcm_s16le" + +# Requests (Generate + Flush pairs) sent ahead of their FlushDone on one session. The +# docs' recipe for multi-request sessions: bounded, so a long document never arrives as +# one burst (which the server closes with 3010), yet enough to keep synthesis busy. +_MAX_IN_FLIGHT = 3 + # Bound on the wait for each protocol frame. The server streams frames continuously # while a synthesis is in flight, so a gap this long means it went silent mid-session; # without a bound, `assembly speak` would hang forever instead of failing cleanly. @@ -56,9 +65,10 @@ class SpeakConfig: """Per-run TTS parameters. Optional fields are only sent when set, so the server applies its own defaults (voice/language/sample_rate) otherwise. - ``voice`` is the catalog name (``jane``) or a full id (``aai-jane``); it rides - the URL as ``voice_id`` — the service retired the bare ``voice`` parameter - (sending it is a 3006 invalid_parameter once that release lands). + ``voice`` is a built-in name (``jane``) or any ``voice_id`` (``aai-jane``, a + UUID, an account voice slug); it rides the URL as ``voice_id`` — the service + retired the bare ``voice`` parameter (sending it is a 3006 invalid_parameter). + ``language`` is normalized to the lowercase English name the service takes. ``extra`` carries any further query params verbatim (the escape hatch for fields the named ones don't cover); the named fields always win a key clash.""" @@ -74,9 +84,10 @@ def query_params(self) -> dict[str, str]: if self.voice is not None: params["voice_id"] = voices.voice_id(self.voice) if self.language is not None: - params["language"] = self.language + params["language"] = voices.language_param(self.language) if self.sample_rate is not None: params["sample_rate"] = str(self.sample_rate) + params["encoding"] = _ENCODING return params @@ -90,26 +101,27 @@ class SpeakResult: def is_available() -> bool: - """True only where the active environment has a streaming-TTS host (sandbox).""" + """True where the active environment has a streaming-TTS host.""" return bool(environments.active().streaming_tts_host) def require_available(command: str) -> None: - """Refuse to run a streaming-TTS command (speak, dub) outside the sandbox, - pointing at the exact --sandbox re-invocation.""" + """Refuse to run a streaming-TTS command (speak, dub, live) in an environment + with no TTS host, pointing back at production.""" if not is_available(): + active = environments.active().name raise CLIError( - f"assembly {command} is only available in the sandbox (it uses streaming TTS).", + f"assembly {command} needs streaming TTS, which the {active} environment lacks.", error_type="unsupported_environment", exit_code=2, - suggestion=f"Re-run as: assembly --sandbox {command} … " - f"(--sandbox goes before the command; or use --env {environments.SANDBOX_ENV}).", + suggestion=f"Drop --env (and unset AAI_ENV) to run assembly {command} in " + f"{environments.DEFAULT_ENV}.", ) def ws_url(params: dict[str, str]) -> str: """The streaming-TTS socket URL for the active environment, with query params.""" - base = f"wss://{environments.active().streaming_tts_host}/v1/ws/" + base = f"wss://{environments.active().streaming_tts_host}/v1/ws" return f"{base}?{urlencode(params)}" if params else base @@ -138,14 +150,12 @@ def _consume_audio_frame( pcm: bytearray, sample_rate: int, on_audio: Callable[[bytes, int], None] | None, -) -> bool: - """Append one Audio frame's PCM, stream it to ``on_audio``, and report whether - it is the final frame (so the collection loop can stop).""" +) -> None: + """Append one Audio frame's PCM and stream it to ``on_audio``.""" chunk = _decode_audio_frame(msg) pcm.extend(chunk) if on_audio is not None: on_audio(chunk, sample_rate) - return bool(msg.get("is_final")) def _error_frame_detail(msg: dict[str, object]) -> str: @@ -184,71 +194,93 @@ def _default_connect( ) -def _run_protocol( - ws: _WebSocket, - config: SpeakConfig, - on_warning: Callable[[str], None] | None, - on_audio: Callable[[bytes, int], None] | None, -) -> SpeakResult: - """Send Generate + Flush, collect Audio until is_final, then Terminate. - - Each decoded Audio chunk is handed to ``on_audio(chunk, sample_rate)`` (when - given) as it arrives, so a caller can play it immediately instead of waiting - for the whole synthesis; the full PCM is still accumulated and returned.""" +def _begin_sample_rate(ws: _WebSocket) -> int: + """Read the server's first frame — Begin, or the Error that replaces it — and + return the session's sample rate from Begin's echoed configuration.""" begin = json.loads(_recv_raw(ws)) begin_type = begin.get("type") if begin_type == "Error": # The server refused the session and put the reason in the frame — surface it - # (e.g. a Bearer-vs-raw auth mismatch reads as the auth error here), rather - # than discarding it behind a generic "got 'Error'". + # (e.g. a rejected key or an unknown voice_id), rather than discarding it + # behind a generic "got 'Error'". raise APIError(f"TTS service rejected the session {_error_frame_detail(begin)}") if begin_type != "Begin": raise APIError(f"TTS service did not start the session (got {begin_type!r}).") - sample_rate = int(begin.get("configuration", {}).get("sample_rate", _DEFAULT_SAMPLE_RATE)) + return int(begin.get("configuration", {}).get("sample_rate", _DEFAULT_SAMPLE_RATE)) + - ws.send(json.dumps({"type": "Generate", "text": config.text})) - # "Flush" forces synthesis of any buffered text; the server rejects the older - # "ForceFlushTextBuffer" tag with a validation error (matches the template). +def _send_request(ws: _WebSocket, text: str) -> None: + """One request: the text, concluded by a Flush (which earns exactly one FlushDone).""" + ws.send(json.dumps({"type": "Generate", "text": text})) ws.send(json.dumps({"type": "Flush"})) - pcm = bytearray() + +def _await_termination(ws: _WebSocket) -> None: + """End the session the documented way: Terminate, then read up to Termination.""" + ws.send(json.dumps({"type": "Terminate"})) while True: + msg = json.loads(_recv_raw(ws)) + mtype = msg.get("type") + if mtype == "Termination": + return + if mtype == "Error": + raise APIError(f"TTS error {_error_frame_detail(msg)}") + + +def _run_protocol( + ws: _WebSocket, + texts: list[str], + on_warning: Callable[[str], None] | None, + on_audio: Callable[[bytes, int], None] | None, +) -> SpeakResult: + """Speak ``texts`` as consecutive requests on one session, then terminate it. + + Requests go out at most ``_MAX_IN_FLIGHT`` ahead of their FlushDone (the next + is sent as each one completes), which is the docs' multi-request pattern. Each + decoded Audio chunk is handed to ``on_audio(chunk, sample_rate)`` (when given) + as it arrives, so a caller can play it immediately; the full PCM is still + accumulated and returned.""" + sample_rate = _begin_sample_rate(ws) + pending = list(texts) + in_flight = 0 + while pending and in_flight < _MAX_IN_FLIGHT: + _send_request(ws, pending.pop(0)) + in_flight += 1 + + pcm = bytearray() + while in_flight: msg = json.loads(_recv_raw(ws)) mtype = msg.get("type") if mtype == "Audio": - if _consume_audio_frame(msg, pcm, sample_rate, on_audio): - break + _consume_audio_frame(msg, pcm, sample_rate, on_audio) elif mtype == "FlushDone": - # The live server ends a synthesis with FlushDone (its Audio frames carry - # no is_final flag), so this is the real end-of-stream marker — stop here, - # or the loop blocks until the recv timeout and the audio is lost. - break + # Exactly one per Flush, in order: every byte of that request is delivered. + in_flight -= 1 + if pending: + _send_request(ws, pending.pop(0)) + in_flight += 1 elif mtype == "Error": raise APIError(f"TTS error {_error_frame_detail(msg)}") elif mtype == "Warning" and on_warning is not None: on_warning(str(msg.get("warning", ""))) + # Other frame types (e.g. WordBoundaries) are ignored, as the protocol requires. - with contextlib.suppress(Exception): - ws.send(json.dumps({"type": "Terminate"})) - + _await_termination(ws) return SpeakResult(bytes(pcm), sample_rate, _pcm_duration_seconds(pcm, sample_rate)) -def synthesize( +def _synthesize_texts( api_key: str, config: SpeakConfig, + texts: list[str], *, - connect: _Connect | None = None, - on_warning: Callable[[str], None] | None = None, - on_audio: Callable[[bytes, int], None] | None = None, + connect: _Connect | None, + on_warning: Callable[[str], None] | None, + on_audio: Callable[[bytes, int], None] | None, ) -> SpeakResult: - """Open the streaming-TTS socket and synthesize ``config.text`` to PCM. - - ``connect`` defaults to websockets' synchronous client; injectable for tests. - ``on_audio(chunk, sample_rate)``, when given, receives each PCM chunk as it - arrives (for incremental playback); the full PCM is still returned regardless. - Connect/session failures map to a clean CLIError (a rejected key -> exit 4). - """ + """Open one streaming-TTS session configured by ``config`` and speak ``texts`` + on it as consecutive requests. Connect/session failures map to a clean CLIError + (a rejected key -> exit 4).""" wsutil.silence_websockets_logging() if connect is None: connect = _default_connect @@ -264,16 +296,16 @@ def synthesize( # Bearer token upgrades fine but is rejected in-band as an Error frame. bearer=False, max_size=None, # no frame cap: a synthesis's Audio frames can exceed the 1 MiB default - # Disable websockets' keepalive pong deadline. A long input (notably a --url page or - # PDF, sent as one Generate frame) can leave the server silent for longer than the - # default 20s ping_timeout before it streams the first Audio frame; websockets would - # then close the still-alive connection itself with code 1011 ("keepalive ping - # timeout"). _RECV_TIMEOUT_SECONDS already bounds server silence per frame, so it is - # the single liveness authority — pings still flow, but a slow pong no longer kills us. + # Disable websockets' keepalive pong deadline. A long request can leave the server + # silent for longer than the default 20s ping_timeout before it streams the first + # Audio frame; websockets would then close the still-alive connection itself with + # code 1011 ("keepalive ping timeout"). _RECV_TIMEOUT_SECONDS already bounds server + # silence per frame, so it is the single liveness authority — pings still flow, but + # a slow pong no longer kills us. ping_timeout=None, ) try: - return _run_protocol(ws, config, on_warning, on_audio) + return _run_protocol(ws, texts, on_warning, on_audio) except (CLIError, KeyboardInterrupt, BrokenPipeError): raise # clean CLI errors, Ctrl-C, and a closed pipe are handled upstream except Exception as exc: @@ -283,6 +315,25 @@ def synthesize( ws.close() +def synthesize( + api_key: str, + config: SpeakConfig, + *, + connect: _Connect | None = None, + on_warning: Callable[[str], None] | None = None, + on_audio: Callable[[bytes, int], None] | None = None, +) -> SpeakResult: + """Open the streaming-TTS socket and synthesize ``config.text`` to PCM. + + ``connect`` defaults to websockets' synchronous client; injectable for tests. + ``on_audio(chunk, sample_rate)``, when given, receives each PCM chunk as it + arrives (for incremental playback); the full PCM is still returned regardless. + """ + return _synthesize_texts( + api_key, config, [config.text], connect=connect, on_warning=on_warning, on_audio=on_audio + ) + + def synthesize_chunked( api_key: str, config: SpeakConfig, @@ -291,29 +342,28 @@ def synthesize_chunked( on_warning: Callable[[str], None] | None = None, on_audio: Callable[[bytes, int], None] | None = None, ) -> SpeakResult: - """Synthesize ``config.text`` as a sequence of sentence-packed chunks, one - streaming-TTS connection each, concatenating the PCM. - - PocketTTS is a streaming model meant to be fed incrementally; a whole document in a - single Generate frame stalls the server (it then misses the keepalive ping and the - socket closes with a 1011). Chunking keeps every Generate small and starts playback - on the first chunk. ``on_audio`` still receives each PCM chunk as it arrives and the - full PCM is returned for the summary, exactly like ``synthesize`` — this is the same - one-connection-per-sentence pattern the agent-cascade path uses. + """Synthesize ``config.text`` as sentence-packed chunks, each its own request on + one streaming-TTS session. + + A single Generate holds at most 2000 characters, and the server closes a session + that receives a whole document as one burst, so a long text (a --url page, a PDF) + goes out as bounded requests on one socket — the documented multi-request pattern, + rather than a socket per chunk, which pays the setup each time and runs into the + per-account new-session limit (3009). Playback starts on the first chunk; + ``on_audio`` and the returned PCM behave exactly like ``synthesize``. """ - pcm = bytearray() - sample_rate = _DEFAULT_SAMPLE_RATE - for chunk in text.chunk_text(config.text): - result = synthesize( - api_key, - replace(config, text=chunk), - connect=connect, - on_warning=on_warning, - on_audio=on_audio, - ) - pcm.extend(result.pcm) - sample_rate = result.sample_rate - return SpeakResult(bytes(pcm), sample_rate, _pcm_duration_seconds(pcm, sample_rate)) + chunks = text.chunk_text(config.text) + if not chunks: + # Nothing to speak: no session at all, rather than one that synthesizes nothing. + return SpeakResult(b"", _DEFAULT_SAMPLE_RATE, 0.0) + return _synthesize_texts( + api_key, + config, + chunks, + connect=connect, + on_warning=on_warning, + on_audio=on_audio, + ) def synthesize_dialogue( diff --git a/aai_cli/tts/text.py b/aai_cli/tts/text.py index af99781f..7560a8b7 100644 --- a/aai_cli/tts/text.py +++ b/aai_cli/tts/text.py @@ -1,9 +1,9 @@ """Text chunking for streaming TTS, built on LangChain's text splitters. -PocketTTS (the streaming-TTS model behind ``assembly speak``) is fed incrementally — -a whole document in a single ``Generate`` frame stalls the server. ``chunk_text`` breaks -the input into sentence-aligned chunks small enough to synthesize one connection at a -time (see ``tts.session.synthesize_chunked``). The splitting is delegated to LangChain's +Streaming TTS takes text in bounded requests — a single ``Generate`` frame holds at +most 2000 characters, and a whole document sent as one burst closes the session. +``chunk_text`` breaks the input into sentence-aligned chunks, each sent as its own +request on one session (see ``tts.session.synthesize_chunked``). The splitting is delegated to LangChain's ``RecursiveCharacterTextSplitter`` (https://docs.langchain.com/oss/python/integrations/splitters) rather than a hand-rolled sentence scanner: it recurses through the separator list below, preferring sentence boundaries and only falling back to words/characters when a single @@ -14,10 +14,9 @@ from langchain_text_splitters import RecursiveCharacterTextSplitter -# Conservative upper bound on the characters in a single Generate frame. PocketTTS is a -# streaming model with a bounded context; everywhere else in the codebase it is fed one -# sentence at a time. Sentences are packed up to this budget to keep the connection count -# down on a long page while keeping each frame comfortably small. +# Conservative upper bound on the characters in a single Generate frame — well under the +# service's 2000-character limit. Sentences are packed up to this budget to keep the +# request count down on a long page while keeping each request comfortably small. _MAX_CHUNK_CHARS = 500 # pragma: no mutate -- a +-1 char budget is immaterial # Separators in descending priority: a sentence terminator followed by a space is the diff --git a/aai_cli/tts/voice_list.py b/aai_cli/tts/voice_list.py index 3eea49b8..629f69a8 100644 --- a/aai_cli/tts/voice_list.py +++ b/aai_cli/tts/voice_list.py @@ -1,11 +1,11 @@ -"""The live streaming-TTS voice listing: ``GET /v1/voices`` on the TTS host. - -Backs ``--list-voices`` on `speak`, `dub`, and `live`. The service is the source of -truth for which voices exist (library voices come and go without a CLI release), -so the listing is fetched, not read from the static ``voices`` catalog — which -only drives the language-driven defaults and shell completion. Like -``core/sync_stt.py``, every failure is normalized into the CLIError hierarchy and -the module stays Rich-free. +"""The streaming-TTS voice listing behind ``--list-voices`` on `speak`, `dub`, and `live`. + +Two sources, per the TTS docs (/docs/tts/voices): the built-in voices (the static +``voices`` catalog — they speak every language) and the account's own voices, which +the service lists at ``GET /v1/voices`` on the TTS host (its default scope; cloned +voices come and go without a CLI release, so they're fetched). Each record's ``slug`` +is the ``voice_id`` to pass as ``--voice``. Like ``core/dictation.py``, every failure +is normalized into the CLIError hierarchy and the module stays Rich-free. """ from __future__ import annotations @@ -14,11 +14,15 @@ from aai_cli.core import environments, jsonshape from aai_cli.core.errors import APIError, auth_failure +from aai_cli.tts import voices _TIMEOUT = 15.0 _HTTP_ERROR_MIN_STATUS = 400 -# The per-voice fields worth a column in the human listing, when the service sends them. +# The largest page the listing serves; pages are followed via next_cursor. +_PAGE_LIMIT = 100 +# The per-voice fields worth a column in the human listing, when the record has them. _DETAIL_KEYS = ("name", "language", "description") +_BUILTIN_DESCRIPTION = "built-in, speaks every language" def voices_url() -> str: @@ -34,45 +38,68 @@ def _raise_for_error(resp: httpx.Response) -> None: raise APIError(f"Could not list TTS voices ({resp.status_code}): {detail}") -def _parse(resp: httpx.Response) -> list[dict[str, object]]: - """The voice objects out of the response: either a bare JSON array or an object - wrapping it under ``voices``. Other shapes are a malformed response.""" +def _parse(resp: httpx.Response) -> tuple[list[dict[str, object]], str | None]: + """One page: its voice objects (under ``voices``) and the cursor of the next page, + or None on the last one. Other shapes are a malformed response.""" try: body: object = resp.json() except ValueError as exc: raise APIError("The TTS service returned a voice list that is not valid JSON.") from exc wrapper = jsonshape.as_mapping(body) - listing = jsonshape.as_object_list(wrapper.get("voices") if wrapper is not None else body) - if listing is None: + listing = jsonshape.as_object_list(wrapper.get("voices")) if wrapper is not None else None + if wrapper is None or listing is None: raise APIError("The TTS service returned a voice list in an unexpected shape.") - return listing + cursor = wrapper.get("next_cursor") + has_more = wrapper.get("has_more") is True + return listing, cursor if has_more and isinstance(cursor, str) else None + + +def builtin_voices() -> list[dict[str, object]]: + """The built-in voices in the listing's record shape.""" + return [ + {"slug": voices.voice_id(name), "name": name.title(), "description": _BUILTIN_DESCRIPTION} + for name in voices.VOICES + ] def fetch_voices(api_key: str) -> list[dict[str, object]]: - """The service's voices, each a JSON object carrying at least its ``voice_id``.""" - # Streaming TTS authenticates with the raw API key (no Bearer prefix), like its socket. + """The account's own voices (every page), each a JSON object with its ``slug``.""" + listing: list[dict[str, object]] = [] + params: dict[str, str | int] = {"limit": _PAGE_LIMIT} + # Streaming TTS takes the raw API key in Authorization, like its socket. try: with httpx.Client(timeout=_TIMEOUT) as client: - resp = client.get(voices_url(), headers={"authorization": api_key}) + while True: + resp = client.get(voices_url(), params=params, headers={"authorization": api_key}) + _raise_for_error(resp) + page, cursor = _parse(resp) + listing.extend(page) + if cursor is None: + return listing + params = {"limit": _PAGE_LIMIT, "cursor": cursor} except httpx.HTTPError as exc: raise APIError( f"Could not reach the TTS service: {exc}", suggestion="Check your network connection and try again.", ) from exc - _raise_for_error(resp) - return _parse(resp) + + +def list_voices(api_key: str) -> list[dict[str, object]]: + """Every voice ``--voice`` accepts: the built-in voices, then the account's own.""" + return builtin_voices() + fetch_voices(api_key) def format_voice_list(listing: list[dict[str, object]]) -> str: - """One line per voice: the ``voice_id`` (what ``--voice`` accepts), then any - name/language/description the service sent, so the output stays useful if + """One line per voice: the ``slug`` (what ``--voice`` accepts), then any + name/language/description the record carries, so the output stays useful if the record shape grows.""" if not listing: return "No voices available." - ids = [str(voice.get("voice_id", "?")) for voice in listing] - width = max(len(voice_id) for voice_id in ids) + slugs = [str(voice.get("slug", "?")) for voice in listing] + width = max(len(slug) for slug in slugs) lines: list[str] = [] - for voice_id, voice in zip(ids, listing, strict=True): + # slugs is derived from listing, so the lengths always match: strict is unobservable. + for slug, voice in zip(slugs, listing, strict=True): # pragma: no mutate details = " · ".join(str(voice[key]) for key in _DETAIL_KEYS if voice.get(key)) - lines.append(f"{voice_id:<{width}} {details}".rstrip()) + lines.append(f"{slug:<{width}} {details}".rstrip()) return "\n".join(lines) diff --git a/aai_cli/tts/voices.py b/aai_cli/tts/voices.py index 5acde09d..a7ef1cc4 100644 --- a/aai_cli/tts/voices.py +++ b/aai_cli/tts/voices.py @@ -1,27 +1,62 @@ -"""The streaming-TTS voice catalog: every voice speaks exactly one language. - -When no ``--voice`` is chosen, `assembly speak` and `assembly dub` pick the -voice from the requested language: a non-English language switches to that -language's native voice(s) — most ship exactly one, so the language alone -selects the voice — while English keeps the curated multi-speaker rotation. - -On the wire a voice is addressed by its ``voice_id`` (``aai-`` for the -preset and library voices; the bare ``voice=`` parameter is retired), so -``voice_id`` maps a catalog name to the id the socket URL carries. The live -listing (`--list-voices`) comes from the service's ``GET /v1/voices`` instead — -see ``voice_list``; this static catalog only drives defaults and completion. +"""The streaming-TTS voice catalog: the service's built-in voices and languages. + +The built-in voices (documented at /docs/tts/voices) all speak every supported +language — the session's ``language`` connect parameter picks the language, +not the voice — so the default voice and the multi-speaker rotation are the +same whatever the language. + +On the wire a built-in voice is addressed by its library slug (``aai-``) +as ``voice_id``; the bare ``voice=`` parameter is retired. Every other +value — a voice's UUID or one of the account's own (e.g. cloned) voice slugs — +is sent as given. ``--list-voices`` adds the account's own voices from the +service's ``GET /v1/voices`` — see ``voice_list``. """ from __future__ import annotations -# The id prefix the service puts on its own (preset + library) voices. +# The slug prefix of the service's own library voices. VOICE_ID_PREFIX = "aai-" +# The built-in voices; each speaks every language in LANGUAGES. +VOICES: tuple[str, ...] = ( + "alba", + "anna", + "charles", + "eve", + "george", + "jane", + "jean", + "mary", + "michael", + "paul", + "vera", +) + +# The languages the service speaks, keyed by ISO 639-1 code. The `language` connect +# parameter takes the lowercase English name, not the code. +LANGUAGES: dict[str, str] = { + "de": "german", + "en": "english", + "es": "spanish", + "fr": "french", + "it": "italian", + "pt": "portuguese", +} + +_NAME_TO_CODE = {name: code for code, name in LANGUAGES.items()} + +# The voice used when none is chosen (the service's own default, too). +DEFAULT_VOICE = "jane" + +# A curated subset that keeps multi-speaker output varied; any language. +DEFAULT_ROTATION = ("jane", "michael", "mary", "paul", "eve", "george") + def voice_id(voice: str) -> str: - """The ``voice_id`` for a voice: ``jane`` -> ``aai-jane``. A value already - carrying the prefix (as ``--list-voices`` prints them) passes through.""" - return voice if voice.startswith(VOICE_ID_PREFIX) else f"{VOICE_ID_PREFIX}{voice}" + """The ``voice_id`` for a voice: a built-in name gets its library slug + (``jane`` -> ``aai-jane``); anything else (``aai-jane``, a UUID, or one of + the account's own voice slugs) is already a ``voice_id`` and passes through.""" + return f"{VOICE_ID_PREFIX}{voice}" if voice in VOICES else voice def voice_name(voice: str) -> str: @@ -30,83 +65,21 @@ def voice_name(voice: str) -> str: return voice.removeprefix(VOICE_ID_PREFIX) -# Voice id -> ISO 639-1 code of the (single) language the voice speaks. -VOICE_LANGUAGES: dict[str, str] = { - "alba": "en", - "anna": "en", - "azelma": "en", - "bill_boerst": "en", - "caro_davy": "en", - "charles": "en", - "cosette": "en", - "eponine": "en", - "estelle": "fr", - "eve": "en", - "fantine": "en", - "george": "en", - "giovanni": "it", - "jane": "en", - "javert": "en", - "jean": "en", - "juergen": "de", - "lola": "es", - "marius": "en", - "mary": "en", - "michael": "en", - "paul": "en", - "peter_yearsley": "en", - "rafael": "pt", - "stuart_bell": "en", - "vera": "en", -} - -# The language names the TTS `language` param uses, keyed by ISO code. Only -# languages with at least one catalog voice belong here (rotation_for relies -# on that invariant to never resolve to an empty rotation). Deliberately -# narrower than dub_exec.LANGUAGE_NAMES, which also lists voiceless languages -# the translator supports. -_LANGUAGE_NAMES: dict[str, str] = { - "de": "German", - "en": "English", - "es": "Spanish", - "fr": "French", - "it": "Italian", - "pt": "Portuguese", -} - -_NAME_TO_CODE = {name.casefold(): code for code, name in _LANGUAGE_NAMES.items()} - -# English has many voices; this curated subset keeps multi-speaker output -# varied with the confirmed-working voices. Non-English languages rotate -# through their own (usually single) native voices instead. -ENGLISH_ROTATION = ("jane", "michael", "mary", "paul", "eve", "george") - - def language_code(language: str | None) -> str | None: - """Normalize a language value (ISO code or name, any case) to its code, - or None when it has no catalog voices — the TTS service and the dub - translator accept more languages than the catalog covers.""" + """Normalize a language value (ISO code or name, any case) to its code, or + None when the service doesn't speak it.""" if language is None: return None cleaned = language.strip().casefold() - if cleaned in _LANGUAGE_NAMES: + if cleaned in LANGUAGES: return cleaned return _NAME_TO_CODE.get(cleaned) -def rotation_for(language: str | None) -> tuple[str, ...]: - """The default voice rotation for a language. - - English — and any language without catalog voices — keeps the curated - English rotation; a language with native voices rotates through those, so - a single-voice language always switches to its one voice. - """ +def language_param(language: str) -> str: + """The ``language`` connect parameter for a language value: the lowercase + English name (``de`` / ``German`` -> ``german``). A language the service + doesn't speak passes through lowercased, so the server's own 3006 error — + which lists the supported set — reaches the user.""" code = language_code(language) - if code is None or code == "en": - return ENGLISH_ROTATION - return tuple(voice for voice, spoken in VOICE_LANGUAGES.items() if spoken == code) - - -def default_voice(language: str | None) -> str: - """The voice used when none is chosen: the language's first rotation voice.""" - return rotation_for(language)[0] + return LANGUAGES[code] if code is not None else language.strip().casefold() diff --git a/examples/dictation-hammerspoon/README.md b/examples/dictation-hammerspoon/README.md index b21bafa8..242a58c6 100644 --- a/examples/dictation-hammerspoon/README.md +++ b/examples/dictation-hammerspoon/README.md @@ -62,7 +62,7 @@ Edit the config block at the top of `dictation.lua`: | Setting | What it does | | --- | --- | | `M.hotkey` | The push-to-talk chord (default `⌃⌥D`). Hammerspoon can't bind a bare `fn` key, so use a modifier+letter. | -| `M.dictateArgs` | Extra `assembly dictate` flags, e.g. `{ "--language", "es" }` to dictate in Spanish, or repeated `--word-boost` to bias tricky terms. | +| `M.dictateArgs` | Extra `assembly dictate` flags, e.g. `{ "--language", "es" }` to dictate in Spanish, repeated `--keyterm` to bias tricky terms, or `--verbatim` for the unedited transcript. | | `M.insertMode` | `"paste"` (clipboard + ⌘V, fast, restores your clipboard) or `"type"` (simulated keystrokes, works everywhere). | | `M.sounds` | Play the mic-open / done chimes for audible feedback. | @@ -71,7 +71,7 @@ Edit the config block at the top of `dictation.lua`: - **PATH**: Hammerspoon launches with a minimal `PATH`, so the script resolves `assembly` through a login shell (`command -v assembly`). If you installed the CLI somewhere unusual, hard-code the path in `resolveAssembly()`. -- **Limits**: each utterance is capped at 120 s (the Sync STT API limit); pass +- **Limits**: each utterance is capped at 120 s (the Dictation API limit); pass `--max-seconds` to stop sooner. - **Toggle instead of hold**: prefer tap-to-start / tap-to-stop? Bind only the pressed callback to a function that calls `startDictation()` when idle and diff --git a/examples/dictation-hammerspoon/dictation.lua b/examples/dictation-hammerspoon/dictation.lua index d0a4be09..4d2936ea 100644 --- a/examples/dictation-hammerspoon/dictation.lua +++ b/examples/dictation-hammerspoon/dictation.lua @@ -28,7 +28,7 @@ M.hotkey = { mods = { "ctrl", "alt" }, key = "d" } -- Extra arguments passed to every `assembly dictate` run, e.g.: -- { "--language", "es" } -- dictate in Spanish --- { "--word-boost", "AssemblyAI", "--word-boost", "LeMUR" } -- bias terms +-- { "--keyterm", "AssemblyAI", "--keyterm", "LeMUR" } -- bias terms M.dictateArgs = {} -- How to insert the transcript: diff --git a/scripts/generated_code_compile_gate.py b/scripts/generated_code_compile_gate.py index bd71efdf..d9e29936 100644 --- a/scripts/generated_code_compile_gate.py +++ b/scripts/generated_code_compile_gate.py @@ -117,10 +117,8 @@ def main() -> int: ), ), ( - # Sandbox-only: streaming TTS has no prod host, so --sandbox makes the URLs valid. "live-basic", ( - "--sandbox", "live", "--voice", "jane", diff --git a/tests/__snapshots__/test_snapshots_help_root.ambr b/tests/__snapshots__/test_snapshots_help_root.ambr index aeec15c8..3d3cc1f0 100644 --- a/tests/__snapshots__/test_snapshots_help_root.ambr +++ b/tests/__snapshots__/test_snapshots_help_root.ambr @@ -48,15 +48,14 @@ │ dictate Signal-driven dictation: record the mic, get the transcript │ │ back │ │ agent Hold a live two-way voice conversation with the Voice Agent API │ - │ live [sandbox] Talk live to a tool-using voice agent │ + │ live Talk live to a tool-using voice agent │ │ control Drive your Mac hands-free: speak an instruction, an agent acts │ │ on the UI │ - │ speak [sandbox] Synthesize speech from text with AssemblyAI streaming │ - │ TTS │ + │ speak Synthesize speech from text with AssemblyAI streaming TTS │ │ llm Send a prompt to AssemblyAI's LLM Gateway and print the reply │ │ clip Cut clips from media by speaker, text match, LLM pick, or time │ │ range │ - │ dub [sandbox] Dub a video or audio file into another language │ + │ dub Dub a video or audio file into another language │ │ caption Burn always-visible captions into a video │ │ eval Transcribe one or more datasets and score WER against their │ │ reference texts │ diff --git a/tests/__snapshots__/test_snapshots_help_run.ambr b/tests/__snapshots__/test_snapshots_help_run.ambr index 101ba2f5..51257c3b 100644 --- a/tests/__snapshots__/test_snapshots_help_run.ambr +++ b/tests/__snapshots__/test_snapshots_help_run.ambr @@ -326,37 +326,55 @@ Recording starts immediately and runs headless — no terminal needed — so a hotkey tool like Hammerspoon can launch it as a background task and send SIGTERM (kill -TERM, task:terminate()) to stop. On SIGTERM the utterance is - sent to the AssemblyAI Sync API, the transcript prints right away (no - polling), and dictate exits, so it flows straight to the next command in a - pipe. The recording can be up to 120 seconds long. Ctrl-C (SIGINT) cancels - without transcribing. + sent to the AssemblyAI Dictation API, which returns send-ready text — filler + removed, self-corrections resolved, punctuation applied — that prints right + away, and dictate exits, so it flows straight to the next command in a pipe. + --instruction asks for a different shape; --verbatim prints the unedited + transcript. The recording can be up to 120 seconds long. Ctrl-C (SIGINT) + cancels without transcribing. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --language TEXT ISO 639-1 language code, │ - │ or a comma-separated list │ - │ for code-switching audio │ - │ (default: en) │ - │ --prompt TEXT Custom transcription │ - │ prompt (overrides │ - │ --language) │ - │ --word-boost TEXT Bias recognition toward a │ - │ term (repeatable) │ - │ --device INTEGER Microphone device index │ - │ --max-seconds FLOAT RANGE Auto-stop a recording │ - │ [1.0<=x<=120.0] after this many seconds │ - │ [default: 120.0] │ - │ --json -j Emit one JSON object per │ - │ utterance │ - │ --output -o [text|json] Output mode: text (the │ - │ bare transcript per │ - │ utterance, pipe-friendly) │ - │ or json │ - │ --help Show this message and │ - │ exit. │ + │ --language TEXT ISO 639-1 language code, │ + │ or a comma-separated list │ + │ for code-switching audio │ + │ (default: no language │ + │ steering) │ + │ --prompt TEXT Context for the │ + │ transcription: what the │ + │ audio is about (e.g. "A │ + │ doctor dictating a visit │ + │ note") │ + │ --keyterm TEXT Bias recognition toward a │ + │ term, such as a name │ + │ (repeatable) │ + │ --instruction TEXT Describe the shape you │ + │ want the text in; │ + │ replaces the default │ + │ cleanup │ + │ --rewrite-mode [fast|smart] Rewrite model tier: fast │ + │ (default, lowest latency) │ + │ or smart (follows │ + │ detailed instructions │ + │ more reliably) │ + │ --verbatim Print the verbatim │ + │ transcript instead of the │ + │ cleaned-up text │ + │ --device INTEGER Microphone device index │ + │ --max-seconds FLOAT RANGE Auto-stop a recording │ + │ [1.0<=x<=120.0] after this many seconds │ + │ [default: 120.0] │ + │ --json -j Emit one JSON object per │ + │ utterance │ + │ --output -o [text|json] Output mode: text (the │ + │ bare transcript per │ + │ utterance, pipe-friendly) │ + │ or json │ + │ --help Show this message and │ + │ exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples - Record until SIGTERM, then print the transcript + Record until SIGTERM, then print the cleaned-up text $ assembly dictate Stop the recording and transcribe (e.g. from a hotkey tool) $ kill -TERM $(pgrep -f 'assembly dictate') @@ -365,7 +383,11 @@ Dictate in Spanish $ assembly dictate --language es Bias recognition toward tricky terms - $ assembly dictate --word-boost AssemblyAI --word-boost LeMUR + $ assembly dictate --keyterm AssemblyAI --keyterm LeMUR + Reshape the text instead of the default cleanup + $ assembly dictate --instruction "Format as a bulleted task list" + Print the verbatim transcript, unedited + $ assembly dictate --verbatim Emit the utterance as a JSON object $ assembly dictate --json Pipe the bare transcript onward @@ -380,7 +402,7 @@ Usage: assembly dub [OPTIONS] [MEDIA] - [sandbox] Dub a video or audio file into another language + Dub a video or audio file into another language The whole platform in one command: the media is transcribed with diarized utterance timestamps, each utterance is translated by an LLM Gateway model, @@ -388,9 +410,7 @@ speaker), and ffmpeg lays the new audio over the original — video copied untouched. A YouTube/media-page URL is downloaded first (audio only, or the full video with --video; --download-sections fetches and dubs only a - time slice of it). Streaming TTS only exists in the sandbox - today — run it as 'assembly --sandbox dub' (--sandbox goes before the - subcommand). Requires ffmpeg. + time slice of it). Requires ffmpeg. Batch mode: pipe one path/URL per line with --from-stdin to dub many sources concurrently (--concurrency). Each writes its own .dub.; a @@ -415,8 +435,9 @@ │ michael, paul; see --list-voices), or │ │ SPEAKER=VOICE to pin a diarized │ │ speaker (repeatable, e.g. --voice │ - │ A=jane). Default: the target │ - │ language's native voice(s). │ + │ A=jane). Default: speakers rotate │ + │ through the built-in voices, which │ + │ speak every language. │ │ --out PATH Output file (default: │ │ .dub. next to the │ │ input) │ @@ -453,27 +474,27 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples - Dub a talk into German (sandbox only) - $ assembly --sandbox dub talk.mp4 --lang de + Dub a talk into German + $ assembly dub talk.mp4 --lang de Use a language name instead of a code - $ assembly --sandbox dub talk.mp4 -l Spanish + $ assembly dub talk.mp4 -l Spanish Dub the full video from YouTube - $ assembly --sandbox dub "https://youtube.com/watch?v=ID" -l de --video + $ assembly dub "https://youtube.com/watch?v=ID" -l de --video Dub only the first 15 minutes of a YouTube video - $ assembly --sandbox dub "https://youtube.com/watch?v=ID" -l de --video + $ assembly dub "https://youtube.com/watch?v=ID" -l de --video --download-sections "*0:00-15:00" Dub every speaker with one voice - $ assembly --sandbox dub talk.mp4 -l fr --voice paul + $ assembly dub talk.mp4 -l fr --voice paul Pin a voice per diarized speaker - $ assembly --sandbox dub panel.mp4 -l de --voice A=jane --voice B=paul + $ assembly dub panel.mp4 -l de --voice A=jane --voice B=paul Reuse a finished transcript instead of re-transcribing - $ assembly --sandbox dub talk.mp4 -l de -t TRANSCRIPT_ID + $ assembly dub talk.mp4 -l de -t TRANSCRIPT_ID Choose the output file - $ assembly --sandbox dub talk.mp4 -l de --out talk-german.mp4 + $ assembly dub talk.mp4 -l de --out talk-german.mp4 Dub a whole folder into German - $ find talks -name "*.mp4" | assembly --sandbox dub --from-stdin -l de + $ find talks -name "*.mp4" | assembly dub --from-stdin -l de See the available voices - $ assembly --sandbox dub --list-voices + $ assembly dub --list-voices @@ -581,7 +602,7 @@ Usage: assembly live [OPTIONS] [SOURCE] - [sandbox] Talk live to a tool-using voice agent + Talk live to a tool-using voice agent A real-time spoken conversation, wired client-side from three primitives — Streaming STT, a deepagents brain on the LLM Gateway, and streaming TTS. @@ -590,11 +611,7 @@ use tools mid-conversation — web search, URL fetch, and the AssemblyAI docs — so it - answers like a live multimodal assistant. Because it uses streaming TTS it - only - runs in the sandbox: run it as 'assembly --sandbox live' (--sandbox goes - before - the subcommand). + answers like a live multimodal assistant. Use headphones: the mic stays open while the agent speaks, so on speakers it would hear itself and loop. Pass an audio file/URL (or --sample) to speak a @@ -659,7 +676,8 @@ ╭─ Language model ─────────────────────────────────────────────────────────────╮ │ --model TEXT LLM Gateway model that powers the │ │ agent's replies │ - │ [default: kimi-k2.5] │ + │ [default: │ + │ claude-haiku-4-5-20251001] │ │ --max-tokens INTEGER RANGE [x>=1] Max tokens per reply │ │ [default: 8192] │ │ --llm-config TEXT Set any LLM Gateway request field │ @@ -703,19 +721,19 @@ Examples Start a live voice conversation - $ assembly --sandbox live + $ assembly live Pick a voice and opening line - $ assembly --sandbox live --voice michael --greeting "Hi there" + $ assembly live --voice michael --greeting "Hi there" Give the agent a persona - $ assembly --sandbox live --system-prompt "You are a terse pirate." + $ assembly live --system-prompt "You are a terse pirate." Add your own MCP servers (none load by default) - $ assembly --sandbox live --mcp-config ~/.config/mcp/servers.json + $ assembly live --mcp-config ~/.config/mcp/servers.json Run a conversation without filesystem access - $ assembly --sandbox live --no-files + $ assembly live --no-files See available voices - $ assembly --sandbox live --list-voices + $ assembly live --list-voices Print equivalent Python instead of running - $ assembly --sandbox live --show-code + $ assembly live --show-code @@ -795,15 +813,14 @@ Usage: assembly speak [OPTIONS] [TEXT] - [sandbox] Synthesize speech from text with AssemblyAI streaming TTS + Synthesize speech from text with AssemblyAI streaming TTS Reads text from the argument, piped stdin, or a web page or PDF with --url (its main content is extracted and the boilerplate stripped). Plays the audio through your speakers by default, or writes a WAV with --out. Speaker-labeled input (from 'assembly transcribe --speaker-labels') is detected automatically: the labels are stripped and each speaker gets a - different voice. This feature only exists in the sandbox today — run it - as 'assembly --sandbox speak' (--sandbox goes before the subcommand). + different voice. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ text [TEXT] Text to speak. Omit to read from stdin. │ @@ -819,10 +836,11 @@ │ SPEAKER=VOICE for diarized │ │ input (repeatable, e.g. --voice │ │ A=jane) │ - │ --language TEXT Language of the text. The │ - │ default voice follows it (e.g. │ - │ --language Italian speaks with │ - │ giovanni). │ + │ --language TEXT Language of the text: English, │ + │ Spanish, German, French, │ + │ Italian, or Portuguese (a name │ + │ or ISO code). Every built-in │ + │ voice speaks all six. │ │ [default: English] │ │ --sample-rate INTEGER RANGE [x>=1] Output sample rate in Hz │ │ (positive). Server default if │ @@ -837,20 +855,20 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples - Speak text aloud (sandbox only) - $ assembly --sandbox speak "Hello there, friend." - Pick a language (its native voice is selected automatically) - $ assembly --sandbox speak "Bonjour" --language French + Speak text aloud + $ assembly speak "Hello there, friend." + Speak another language (every built-in voice speaks it) + $ assembly speak "Bonjour" --language French Speak a diarized transcript, one voice per speaker - $ assembly transcribe meeting.mp3 --speaker-labels | assembly --sandbox speak + $ assembly transcribe meeting.mp3 --speaker-labels | assembly speak Override a speaker's voice - $ … | assembly --sandbox speak --voice A=vera --voice B=paul + $ … | assembly speak --voice A=vera --voice B=paul Read a web page aloud (boilerplate stripped) - $ assembly --sandbox speak --url https://example.com/post + $ assembly speak --url https://example.com/post Save to a WAV instead of playing - $ assembly --sandbox speak "Hello" --out /tmp/hello.wav + $ assembly speak "Hello" --out /tmp/hello.wav See the available voices - $ assembly --sandbox speak --list-voices + $ assembly speak --list-voices diff --git a/tests/_tts_session_helpers.py b/tests/_tts_session_helpers.py index cef8a1d1..d37f832b 100644 --- a/tests/_tts_session_helpers.py +++ b/tests/_tts_session_helpers.py @@ -8,8 +8,11 @@ from __future__ import annotations import base64 +import dataclasses import json +import pytest + from aai_cli.core import environments @@ -17,6 +20,14 @@ def use_env(name: str) -> None: environments.set_active(environments.get(name)) +def use_no_tts_production(monkeypatch: pytest.MonkeyPatch) -> None: + """Production with its TTS host cleared — the environment shape + ``session.require_available`` refuses (no real environment lacks one today).""" + env = dataclasses.replace(environments.get("production"), streaming_tts_host="") + monkeypatch.setitem(environments.ENVIRONMENTS, "production", env) + environments.set_active(env) + + class FakeWS: """A minimal stand-in for a websockets sync connection.""" @@ -28,6 +39,10 @@ def __init__(self, incoming: list[str]) -> None: def recv(self, timeout: float | None = None) -> str: self.recv_timeouts.append(timeout) + if not self._incoming and self.sent and self.sent[-1].get("type") == "Terminate": + # Like the real server: Terminate is answered with the session summary, so + # a script needn't spell out the Termination every clean session ends with. + return termination_frame() return self._incoming.pop(0) def send(self, data: str) -> None: @@ -37,27 +52,18 @@ def close(self) -> None: self.closed = True -def audio_frame(pcm: bytes, *, final: bool) -> str: - # An Audio frame with an explicit is_final flag — the defensive end-of-stream path - # (the live server instead omits is_final and ends with FlushDone, see audio_chunk). - # The sample rate is reported once, up front, in the Begin frame's configuration. - return json.dumps( - { - "type": "Audio", - "audio": base64.b64encode(pcm).decode("ascii"), - "is_final": final, - } - ) - - def audio_chunk(pcm: bytes) -> str: - # The real server's Audio frames carry the PCM payload and a flush_id, but NO - # is_final flag — completion is signalled by a separate FlushDone frame. + # The server's Audio frames carry the PCM payload and a flush_id; completion is + # signalled by a separate FlushDone frame. return json.dumps( {"type": "Audio", "audio": base64.b64encode(pcm).decode("ascii"), "flush_id": 0} ) +def termination_frame() -> str: + return json.dumps({"type": "Termination", "session_duration_seconds": 1}) + + def flush_done_frame() -> str: return json.dumps({"type": "FlushDone", "flush_id": 0, "audio_duration_ms": 880}) diff --git a/tests/test_agent_cascade_command.py b/tests/test_agent_cascade_command.py index a51f9bb9..a7c16d1e 100644 --- a/tests/test_agent_cascade_command.py +++ b/tests/test_agent_cascade_command.py @@ -26,6 +26,7 @@ from aai_cli.core.errors import CLIError, UsageError from aai_cli.main import app from aai_cli.streaming import turn_presets +from tests._tts_session_helpers import use_no_tts_production runner = CliRunner() @@ -34,7 +35,7 @@ source=None, sample=False, voice="jane", - model="kimi-k2.5", + model="claude-haiku-4-5-20251001", system_prompt="be nice", system_prompt_file=None, greeting="hello", @@ -92,11 +93,11 @@ def test_missing_system_prompt_file_is_rejected_by_typer(): assert "sandbox" not in result.output.lower() -def test_production_env_is_rejected_with_sandbox_hint(): - # Default env is production, which has no streaming-TTS host. +def test_env_without_tts_host_is_rejected(monkeypatch): + use_no_tts_production(monkeypatch) result = runner.invoke(app, ["live", "--voice", "jane"]) assert result.exit_code == 2 - assert "only available in the sandbox" in result.output + assert "needs streaming TTS" in result.output def test_device_with_file_source_is_rejected(monkeypatch): diff --git a/tests/test_agent_cascade_config.py b/tests/test_agent_cascade_config.py index 4d7280c2..9f192e16 100644 --- a/tests/test_agent_cascade_config.py +++ b/tests/test_agent_cascade_config.py @@ -20,7 +20,7 @@ def test_default_config_values(): config = CascadeConfig() assert config.voice == DEFAULT_VOICE - assert config.model == DEFAULT_MODEL == "kimi-k2.5" # `assembly live` default + assert config.model == DEFAULT_MODEL == "claude-haiku-4-5-20251001" # `assembly live` default assert config.greeting == DEFAULT_GREETING # The default prompt drives brevity (a sentence or two) and bans markup, since the # reply is spoken. Pin each clause against an independent literal so a mutated segment diff --git a/tests/test_agent_cascade_show_code.py b/tests/test_agent_cascade_show_code.py index 6f81d898..539131c2 100644 --- a/tests/test_agent_cascade_show_code.py +++ b/tests/test_agent_cascade_show_code.py @@ -14,6 +14,7 @@ from aai_cli.commands.agent_cascade import _exec from aai_cli.core import config from aai_cli.main import app +from tests._tts_session_helpers import use_no_tts_production # The cascade is sandbox-only and its happy paths run under `--sandbox`, which the # root callback restricts to AssemblyAI logins — run the module as an employee. @@ -107,8 +108,19 @@ def test_show_code_mic_emits_no_warning(monkeypatch): assert "uses the microphone" not in result.stderr # mic script matches the run, nothing to warn -def test_show_code_in_production_is_rejected_with_sandbox_hint(): - # --show-code still honors the sandbox-only guard, so the generated URLs are valid. +def test_show_code_in_production_targets_the_production_tts_host(): + # Production has streaming TTS now, so the generated script dials its host. + result = runner.invoke(app, ["live", "--show-code"]) + assert result.exit_code == 0, result.output + assert ( + "wss://tts.us.assemblyai.com/v1/ws?voice_id=aai-jane&sample_rate=24000&encoding=pcm_s16le" + in result.output + ) + + +def test_show_code_without_a_tts_host_is_rejected(monkeypatch): + # --show-code still honors the TTS-host guard, so the generated URLs are valid. + use_no_tts_production(monkeypatch) result = runner.invoke(app, ["live", "--show-code"]) assert result.exit_code == 2 - assert "only available in the sandbox" in result.output + assert "needs streaming TTS" in result.output diff --git a/tests/test_agent_cascade_voices.py b/tests/test_agent_cascade_voices.py index 8583a599..9f3a0f54 100644 --- a/tests/test_agent_cascade_voices.py +++ b/tests/test_agent_cascade_voices.py @@ -11,11 +11,12 @@ from aai_cli.commands.agent_cascade._exec import run_agent_cascade from aai_cli.core.errors import CLIError, UsageError from aai_cli.tts import voices as tts_voices +from tests._tts_session_helpers import use_no_tts_production from tests.test_agent_cascade_command import _DEFAULTS def test_voice_names_are_the_tts_catalog_sorted(): - assert sorted(tts_voices.VOICE_LANGUAGES) == voices.VOICE_NAMES + assert sorted(tts_voices.VOICES) == voices.VOICE_NAMES def test_default_voice_is_in_catalog(): @@ -28,10 +29,11 @@ def test_complete_voice_filters_by_prefix(): assert all(name.startswith("ja") for name in completions) -def test_voice_id_form_validates_as_its_catalog_name(): +def test_voice_id_form_validates_as_its_catalog_name(monkeypatch): # A voice_id copied from --list-voices (aai-jane) is the catalog voice jane, so it - # passes validation and the run proceeds to the next guard (the sandbox check). - with pytest.raises(CLIError, match="sandbox"): + # passes validation and the run proceeds to the next guard (the TTS-host check). + use_no_tts_production(monkeypatch) + with pytest.raises(CLIError, match="needs streaming TTS"): run_agent_cascade( dataclasses.replace(_DEFAULTS, voice="aai-jane"), AppState(), json_mode=False ) diff --git a/tests/test_code_gen_agent_cascade.py b/tests/test_code_gen_agent_cascade.py index 40b5f838..d017bc9c 100644 --- a/tests/test_code_gen_agent_cascade.py +++ b/tests/test_code_gen_agent_cascade.py @@ -45,7 +45,7 @@ def test_render_parses_and_wires_all_three_legs(): sandbox = environments.get("sandbox000") # STT, LLM Gateway, and TTS hosts all come from the active (sandbox) environment. assert f"wss://{sandbox.streaming_host}/v3/ws" in code - assert f"wss://{sandbox.streaming_tts_host}/v1/ws/" in code + assert f"wss://{sandbox.streaming_tts_host}/v1/ws?" in code assert sandbox.llm_gateway_base in code assert 'os.environ["ASSEMBLYAI_API_KEY"]' in code @@ -88,7 +88,7 @@ def test_render_no_format_turns_fires_on_bare_end_of_turn(): def test_render_includes_language_only_when_set(): assert "language=" not in _render(language=None) - assert "language=de" in _render(language="de") + assert "language=german" in _render(language="de") def test_render_uses_single_full_duplex_stream(): diff --git a/tests/test_command_options_seam.py b/tests/test_command_options_seam.py index a767a96b..08e6dba6 100644 --- a/tests/test_command_options_seam.py +++ b/tests/test_command_options_seam.py @@ -24,6 +24,7 @@ from aai_cli.core import choices, config, llm from aai_cli.core.errors import CLIError, UsageError from aai_cli.options import DEFAULT_BATCH_CONCURRENCY +from tests._tts_session_helpers import use_no_tts_production # The CLI's flag defaults, as data. Tests override per-case with dataclasses.replace. TRANSCRIBE_DEFAULTS = transcribe_exec.TranscribeOptions( @@ -210,12 +211,12 @@ def raise_broken_pipe(api_key, *, renderer, player, mic, config): assert exc.value.exit_code == 0 -def test_run_speak_requires_sandbox(): - # The active environment defaults to production, which has no streaming-TTS host. +def test_run_speak_requires_a_tts_host(monkeypatch): + use_no_tts_production(monkeypatch) with pytest.raises(CLIError) as exc: speak_exec.run_speak(SPEAK_DEFAULTS, AppState(), json_mode=False) assert exc.value.exit_code == 2 - assert "--sandbox" in (exc.value.suggestion or "") + assert "Drop --env" in (exc.value.suggestion or "") def test_run_llm_follow_rejects_output_field(): diff --git a/tests/test_dictate_command.py b/tests/test_dictate_command.py index 1f98f2d2..2b95516e 100644 --- a/tests/test_dictate_command.py +++ b/tests/test_dictate_command.py @@ -4,6 +4,7 @@ from typer.testing import CliRunner from aai_cli.commands.dictate import _exec as dictate_exec +from aai_cli.core import choices from aai_cli.main import app runner = CliRunner() @@ -27,7 +28,7 @@ def test_defaults_map_to_options(monkeypatch): assert seen["opts"] == dictate_exec.DictateOptions( language=None, prompt=None, - word_boost=None, + keyterms=None, device=None, once=False, max_seconds=120.0, @@ -45,10 +46,17 @@ def test_every_flag_maps_to_its_option_field(monkeypatch): "es", "--prompt", "Verbatim.", - "--word-boost", + "--keyterm", "AssemblyAI", - "--word-boost", + "--keyterm", "LeMUR", + "--word-boost", + "Legacy", + "--instruction", + "Bullets", + "--rewrite-mode", + "smart", + "--verbatim", "--device", "2", "--once", @@ -61,14 +69,23 @@ def test_every_flag_maps_to_its_option_field(monkeypatch): assert seen["opts"] == dictate_exec.DictateOptions( language="es", prompt="Verbatim.", - word_boost=["AssemblyAI", "LeMUR"], + keyterms=["AssemblyAI", "LeMUR"], device=2, once=True, max_seconds=30.0, + instruction="Bullets", + rewrite_mode=choices.RewriteMode.smart, + verbatim=True, + word_boost=["Legacy"], ) assert seen["json_mode"] is True +def test_rewrite_mode_rejects_an_unknown_tier(): + result = runner.invoke(app, ["dictate", "--rewrite-mode", "turbo"]) + assert result.exit_code == 2 + + def test_max_seconds_is_capped_at_the_api_limit(): result = runner.invoke(app, ["dictate", "--max-seconds", "200"]) assert result.exit_code == 2 diff --git a/tests/test_dictate_exec.py b/tests/test_dictate_exec.py index db4f61ce..8c3ecf2f 100644 --- a/tests/test_dictate_exec.py +++ b/tests/test_dictate_exec.py @@ -2,7 +2,7 @@ The session is driven by constructing DictateOptions and injecting the three boundaries — stop_on_terminate (a scripted SIGTERM latch), MicrophoneSource -(canned PCM), and sync_stt.transcribe_pcm (recorded calls) — so no test needs a +(canned PCM), and dictation.transcribe_live (recorded calls) — so no test needs a real signal, microphone, or network. """ @@ -17,23 +17,30 @@ from aai_cli.app.context import AppState from aai_cli.commands.dictate import _exec as dictate_exec -from aai_cli.core import choices, config, sync_stt +from aai_cli.core import choices, config, dictation from aai_cli.core.errors import NotAuthenticated, UsageError DICTATE_DEFAULTS = dictate_exec.DictateOptions( language=None, prompt=None, - word_boost=None, + keyterms=None, device=None, once=False, max_seconds=120.0, ) -# One ~100 ms chunk of 16 kHz PCM16 — comfortably above the 80 ms upload floor. +# One ~100 ms chunk of 16 kHz PCM16. CHUNK = b"\x01\x00" * 1600 -RESULT = sync_stt.SyncTranscript( - text="hello world", confidence=0.9, audio_duration_ms=1500, session_id="sess-1" +# The verbatim transcript differs from the cleanup rewrite, so a test can tell which +# one was printed: the human default is the rewrite ("hello world"). +RESULT = dictation.DictationResult( + text="um, hello world", + llm_response="hello world", + llm_error=None, + confidence=0.9, + audio_duration_ms=1500, + session_id="sess-1", ) @@ -84,14 +91,21 @@ def fake_mic(*, target_rate, device=None, on_open=None): monkeypatch.setattr(dictate_exec, "MicrophoneSource", fake_mic) - def fake_transcribe(api_key, pcm, *, sample_rate, channels=1, **kwargs): + harness["result"] = RESULT + + def fake_transcribe(api_key, pcm, *, sample_rate, channels=1, config=None): harness["calls"].append( - {"api_key": api_key, "pcm": pcm, "sample_rate": sample_rate, "channels": channels} - | kwargs + { + "api_key": api_key, + "pcm": pcm, + "sample_rate": sample_rate, + "channels": channels, + "config": config, + } ) - return RESULT + return harness["result"] - monkeypatch.setattr(dictate_exec.sync_stt, "transcribe_pcm", fake_transcribe) + monkeypatch.setattr(dictate_exec.dictation, "transcribe_live", fake_transcribe) return harness @@ -117,9 +131,7 @@ def test_records_then_prints_bare_transcript(seams, capsys): "pcm": CHUNK + CHUNK, "sample_rate": 16000, "channels": 1, - "language_code": None, - "prompt": None, - "word_boost": None, + "config": dictation.DictationConfig(), } ] captured = capsys.readouterr() @@ -139,7 +151,9 @@ def test_json_mode_emits_one_ndjson_object_per_utterance(seams, capsys): captured = capsys.readouterr() assert json.loads(captured.out) == { "type": "utterance", - "text": "hello world", + "text": "um, hello world", + "llm_response": "hello world", + "llm_error": None, "confidence": 0.9, "audio_duration_ms": 1500, "session_id": "sess-1", @@ -153,7 +167,7 @@ def test_output_json_folds_into_ndjson_without_the_json_flag(seams, capsys): # which is False here) — proving the -o/--output resolution runs. seams["stop"] = FakeStop([True]) _run(dataclasses.replace(DICTATE_DEFAULTS, output_field=choices.TextOrJson.json)) - assert json.loads(capsys.readouterr().out)["text"] == "hello world" + assert json.loads(capsys.readouterr().out)["text"] == "um, hello world" def test_output_text_emits_bare_transcript(seams, capsys): @@ -256,54 +270,85 @@ def chunk_gen(): assert closed == [True] # the device-releasing cleanup ran at stop, not at GC -@pytest.mark.parametrize("size", [200, 2558]) # 2558: just under the exact 2560-byte floor -def test_too_short_recording_is_skipped_with_a_warning(seams, capsys, size): +def test_empty_recording_is_skipped_with_a_warning(seams, capsys): + # An instant SIGTERM captures nothing; the server refuses an empty audio part. seams["stop"] = FakeStop([True]) - seams["chunks"] = [b"\x01" * size] # below 80 ms of 16 kHz PCM16 (2560 bytes) + seams["chunks"] = [b""] _run() assert seams["calls"] == [] captured = capsys.readouterr() assert captured.out == "" - assert "shorter than 80 ms" in captured.err + assert "Nothing was recorded" in captured.err -def test_recording_at_the_80ms_floor_is_transcribed(seams): +def test_a_very_short_recording_is_still_transcribed(seams): + # The live endpoint has no minimum duration, so even one sample goes up. seams["stop"] = FakeStop([True]) - seams["chunks"] = [b"\x01" * 2560] # exactly 80 ms: allowed, not skipped + seams["chunks"] = [b"\x01\x00"] _run() assert len(seams["calls"]) == 1 -def test_language_and_boost_flags_are_forwarded(seams): +def test_flags_map_onto_the_dictation_config(seams): seams["stop"] = FakeStop([True]) - _run(dataclasses.replace(DICTATE_DEFAULTS, language="es", word_boost=["AssemblyAI"])) - assert seams["calls"][0]["language_code"] == "es" - assert seams["calls"][0]["word_boost"] == ["AssemblyAI"] + _run( + dataclasses.replace( + DICTATE_DEFAULTS, + language="en, es", + prompt="A doctor dictating a visit note.", + keyterms=["AssemblyAI"], + instruction="Format as a bulleted list", + rewrite_mode=choices.RewriteMode.smart, + ) + ) + assert seams["calls"][0]["config"] == dictation.DictationConfig( + language_codes=["en", "es"], + stt_prompt="A doctor dictating a visit note.", + keyterms=["AssemblyAI"], + llm_instruction="Format as a bulleted list", + rewrite_mode="smart", + ) -def test_comma_separated_languages_become_a_list(seams): +def test_blank_language_reads_as_unset(seams): seams["stop"] = FakeStop([True]) - _run(dataclasses.replace(DICTATE_DEFAULTS, language="en, es")) - assert seams["calls"][0]["language_code"] == ["en", "es"] + _run(dataclasses.replace(DICTATE_DEFAULTS, language=" , ")) + assert seams["calls"][0]["config"].language_codes is None -def test_blank_language_reads_as_unset(seams): +def test_deprecated_word_boost_joins_the_keyterms_and_warns(seams, capsys): seams["stop"] = FakeStop([True]) - _run(dataclasses.replace(DICTATE_DEFAULTS, language=" , ")) - assert seams["calls"][0]["language_code"] is None + _run(dataclasses.replace(DICTATE_DEFAULTS, keyterms=["A"], word_boost=["B"])) + assert seams["calls"][0]["config"].keyterms == ["A", "B"] + assert "--word-boost is deprecated; use --keyterm." in capsys.readouterr().err -def test_prompt_with_language_warns_that_language_is_ignored(seams, capsys): +def test_word_boost_warning_is_silenced_by_quiet(seams, capsys): seams["stop"] = FakeStop([True]) - _run(dataclasses.replace(DICTATE_DEFAULTS, prompt="Verbatim.", language="es")) - assert "--language is ignored when --prompt is set" in capsys.readouterr().err + _run(dataclasses.replace(DICTATE_DEFAULTS, word_boost=["B"]), AppState(quiet=True)) + assert "deprecated" not in capsys.readouterr().err + + +def test_verbatim_prints_the_unedited_transcript(seams, capsys): + seams["stop"] = FakeStop([True]) + _run(dataclasses.replace(DICTATE_DEFAULTS, verbatim=True)) + assert capsys.readouterr().out.strip() == "um, hello world" + + +def test_failed_rewrite_falls_back_to_the_transcript_with_a_warning(seams, capsys): + seams["stop"] = FakeStop([True]) + seams["result"] = dataclasses.replace(RESULT, llm_response=None, llm_error="timeout") + _run() + captured = capsys.readouterr() + assert captured.out.strip() == "um, hello world" + assert "The cleanup rewrite failed (timeout)" in captured.err -def test_prompt_alone_is_forwarded_without_warning(seams, capsys): +def test_verbatim_skips_the_rewrite_failure_warning(seams, capsys): seams["stop"] = FakeStop([True]) - _run(dataclasses.replace(DICTATE_DEFAULTS, prompt="Verbatim.")) - assert seams["calls"][0]["prompt"] == "Verbatim." - assert "ignored" not in capsys.readouterr().err + seams["result"] = dataclasses.replace(RESULT, llm_response=None, llm_error="error") + _run(dataclasses.replace(DICTATE_DEFAULTS, verbatim=True)) + assert "rewrite failed" not in capsys.readouterr().err def test_transcription_runs_under_the_status_spinner(seams, monkeypatch): diff --git a/tests/test_dictation.py b/tests/test_dictation.py new file mode 100644 index 00000000..5ac453df --- /dev/null +++ b/tests/test_dictation.py @@ -0,0 +1,231 @@ +"""The Dictation API HTTP boundary (aai_cli/core/dictation.py): the documented +request shape (config part first), error normalization across both error body +shapes, and response parsing.""" + +import dataclasses + +import httpx2 as httpx +import pytest + +from aai_cli.core import dictation, environments +from aai_cli.core.errors import APIError, NotAuthenticated + +OK_BODY = { + "text": "Um, I'm calling about, uh, my order.", + "llm_response": "I'm calling about my order.", + "llm_error": None, + "confidence": 0.87, + "audio_duration_ms": 1500, + "session_id": "eb92c4ff", +} + + +def _patch_transport(monkeypatch, handler): + real_client = httpx.Client + seen_kwargs = {} + + def fake_client(*args, **kwargs): + seen_kwargs.update(kwargs) + kwargs["transport"] = httpx.MockTransport(handler) + return real_client(*args, **kwargs) + + monkeypatch.setattr(dictation.httpx, "Client", fake_client) + return seen_kwargs + + +def _ok_handler(seen, body=OK_BODY): + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["auth"] = request.headers.get("authorization") + seen["headers"] = dict(request.headers) + seen["body"] = request.read() + return httpx.Response(200, json=body) + + return handler + + +def _respond(status, **kwargs): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status, **kwargs) + + return handler + + +def test_posts_config_then_pcm_to_the_live_endpoint(monkeypatch): + seen = {} + client_kwargs = _patch_transport(monkeypatch, _ok_handler(seen)) + result = dictation.transcribe_live("sk_key_pcm", b"\x01\x02pcm-bytes", sample_rate=16000) + assert seen["url"] == "https://dictation.assemblyai.com/v1/transcribe/live" + # The raw key, and no Sync-era model header. + assert seen["auth"] == "sk_key_pcm" + assert "x-aai-model" not in seen["headers"] + body = seen["body"] + # The config part must precede the audio part, or the live endpoint answers 400. + assert body.index(b'name="config"') < body.index(b'name="audio"') + assert b"application/json" in body + assert b"\x01\x02pcm-bytes" in body + assert b"audio/pcm" in body + assert b'{"sample_rate": 16000, "channels": 1}' in body + # Generous timeout: the upload can carry up to 2 minutes of audio, then the rewrite. + assert client_kwargs["timeout"] == 90.0 + assert result == dictation.DictationResult( + text="Um, I'm calling about, uh, my order.", + llm_response="I'm calling about my order.", + llm_error=None, + confidence=0.87, + audio_duration_ms=1500, + session_id="eb92c4ff", + ) + + +def test_targets_the_sandbox_host_when_active(monkeypatch): + seen = {} + _patch_transport(monkeypatch, _ok_handler(seen)) + environments.set_active(environments.get("sandbox000")) + dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert seen["url"] == "https://dictation.sandbox000.assemblyai-labs.com/v1/transcribe/live" + + +def test_config_fields_use_the_documented_names_when_set(monkeypatch): + seen = {} + _patch_transport(monkeypatch, _ok_handler(seen)) + config = dictation.DictationConfig( + language_codes=["en", "es"], + stt_prompt="A doctor dictating a visit note.", + keyterms=["AssemblyAI", "LeMUR"], + llm_instruction="Format as a bulleted list", + rewrite_mode="smart", + ) + dictation.transcribe_live("sk", b"pcm", sample_rate=44100, channels=2, config=config) + assert ( + b'{"sample_rate": 44100, "channels": 2, "language_codes": ["en", "es"], ' + b'"stt_prompt": "A doctor dictating a visit note.", ' + b'"keyterms_prompt": ["AssemblyAI", "LeMUR"], ' + b'"llm_instruction": "Format as a bulleted list", "rewrite_mode": "smart"}' + ) in seen["body"] + + +def test_result_is_immutable(monkeypatch): + _patch_transport(monkeypatch, _ok_handler({})) + result = dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + field_name = dataclasses.fields(result)[0].name + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(result, field_name, "mutated") + + +def test_final_text_prefers_the_rewrite(): + result = dictation.DictationResult("raw", "clean", None, None, None, None) + assert result.final_text == "clean" + + +def test_final_text_falls_back_to_the_transcript_when_the_rewrite_failed(): + result = dictation.DictationResult("raw", None, "timeout", None, None, None) + assert result.final_text == "raw" + + +def test_missing_optional_response_fields_parse_as_none(monkeypatch): + _patch_transport(monkeypatch, _respond(200, json={"text": "bare"})) + result = dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert result == dictation.DictationResult( + text="bare", + llm_response=None, + llm_error=None, + confidence=None, + audio_duration_ms=None, + session_id=None, + ) + + +def test_failed_rewrite_is_still_a_result(monkeypatch): + body = {"text": "raw", "llm_response": None, "llm_error": "timeout"} + _patch_transport(monkeypatch, _respond(200, json=body)) + result = dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert (result.llm_response, result.llm_error) == (None, "timeout") + + +@pytest.mark.parametrize("status", [401, 403]) +def test_auth_rejection_raises_not_authenticated(monkeypatch, status): + _patch_transport(monkeypatch, _respond(status, json={"detail": "Invalid API key"})) + with pytest.raises(NotAuthenticated) as exc: + dictation.transcribe_live("bad", b"pcm", sample_rate=16000) + assert exc.value.rejected_key is True + + +@pytest.mark.parametrize("status", [429, 502, 503, 504]) +def test_rate_limit_capacity_and_upstream_are_retryable(monkeypatch, status): + body = {"error_code": "capacity_exceeded", "error": "server at cap"} + _patch_transport(monkeypatch, _respond(status, json=body)) + with pytest.raises(APIError) as exc: + dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert exc.value.message == ( + f"The Dictation API is busy ({status}): server at cap (capacity_exceeded)" + ) + assert exc.value.suggestion == "Wait a moment and try again." + + +def test_dictation_error_shape_carries_error_and_code(monkeypatch): + body = {"error_code": "bad_request", "error": "`config` must be sent before `audio`"} + _patch_transport(monkeypatch, _respond(400, json=body)) + with pytest.raises(APIError) as exc: + dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert exc.value.message == ( + "Dictation failed (400): `config` must be sent before `audio` (bad_request)" + ) + + +def test_detail_shape_wins_over_error(monkeypatch): + # Docs: check `detail` first, falling back to `error`. + body = {"status": 400, "title": "Bad Request", "detail": "unknown field", "error": "x"} + _patch_transport(monkeypatch, _respond(400, json=body)) + with pytest.raises(APIError) as exc: + dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert exc.value.message == "Dictation failed (400): unknown field" + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + (b"upstream proxy says no", "upstream proxy says no"), # non-JSON body + (b"", "HTTP 500"), # empty body -> bare status + (b'["weird"]', '["weird"]'), # JSON but not an object -> raw text + (b'{"unrelated": true}', '{"unrelated": true}'), # object without detail/error + ], +) +def test_error_detail_falls_back_to_raw_body_or_status(monkeypatch, body, expected): + _patch_transport(monkeypatch, _respond(500, content=body)) + with pytest.raises(APIError) as exc: + dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert exc.value.message == f"Dictation failed (500): {expected}" + + +def test_success_with_unparseable_body_is_a_clean_api_error(monkeypatch): + _patch_transport(monkeypatch, _respond(200, content=b"not-json")) + with pytest.raises(APIError) as exc: + dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert "not valid JSON (HTTP 200)" in exc.value.message + + +@pytest.mark.parametrize("payload", [{"words": []}, ["list"]]) +def test_success_without_text_is_a_clean_api_error(monkeypatch, payload): + _patch_transport(monkeypatch, _respond(200, json=payload)) + with pytest.raises(APIError) as exc: + dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert "unexpected response shape" in exc.value.message + + +def test_network_failure_is_a_clean_api_error(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused") + + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError) as exc: + dictation.transcribe_live("sk", b"pcm", sample_rate=16000) + assert "Could not reach the Dictation API" in exc.value.message + assert "network" in (exc.value.suggestion or "") + + +def test_config_is_immutable(): + config = dictation.DictationConfig() + field_name = dataclasses.fields(config)[0].name + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(config, field_name, "mutated") diff --git a/tests/test_dub_command.py b/tests/test_dub_command.py index ea79a145..cd71bc54 100644 --- a/tests/test_dub_command.py +++ b/tests/test_dub_command.py @@ -14,6 +14,7 @@ from aai_cli.core import llm from aai_cli.main import app from tests._clip_helpers import plain +from tests._tts_session_helpers import use_no_tts_production runner = CliRunner() @@ -37,14 +38,13 @@ def test_lang_is_required(): assert "--lang" in plain(result.output) -def test_production_env_is_rejected_with_sandbox_hint(): - result = runner.invoke(app, ["dub", "talk.mp4", "--lang", "de"]) # default = production +def test_env_without_tts_host_is_rejected(monkeypatch): + use_no_tts_production(monkeypatch) + result = runner.invoke(app, ["dub", "talk.mp4", "--lang", "de"]) assert result.exit_code == 2 - output = plain(result.output) - assert "only available in the sandbox" in output - # The suggestion spells out the exact corrected invocation: --sandbox is a root - # flag, so it must go before the command, not after it. - assert "Re-run as: assembly --sandbox dub" in output + output = " ".join(plain(result.output).split()) + assert "assembly dub needs streaming TTS" in output + assert "to run assembly dub in production" in output def test_defaults_map_to_options(captured_run): diff --git a/tests/test_dub_exec.py b/tests/test_dub_exec.py index 61802675..2d9b3a1d 100644 --- a/tests/test_dub_exec.py +++ b/tests/test_dub_exec.py @@ -28,6 +28,7 @@ utterance, write_media, ) +from tests._tts_session_helpers import use_no_tts_production @pytest.fixture @@ -195,14 +196,18 @@ def test_run_dub_rejects_blank_language_first(): dub_exec.run_dub(opts, AppState(), json_mode=False) -def test_run_dub_requires_sandbox(): - # The active environment defaults to production, which has no streaming-TTS host. +def test_run_dub_requires_a_tts_host(monkeypatch): + use_no_tts_production(monkeypatch) with pytest.raises(CLIError) as exc: dub_exec.run_dub(DEFAULTS, AppState(), json_mode=False) assert exc.value.error_type == "unsupported_environment" assert exc.value.exit_code == 2 - assert "only available in the sandbox" in exc.value.message - assert "Re-run as: assembly --sandbox dub" in (exc.value.suggestion or "") + assert exc.value.message == ( + "assembly dub needs streaming TTS, which the production environment lacks." + ) + assert exc.value.suggestion == ( + "Drop --env (and unset AAI_ENV) to run assembly dub in production." + ) def test_run_dub_rejects_missing_file(sandbox, tmp_path): diff --git a/tests/test_dub_pipeline.py b/tests/test_dub_pipeline.py index 4e9ac57b..c9beb95b 100644 --- a/tests/test_dub_pipeline.py +++ b/tests/test_dub_pipeline.py @@ -99,13 +99,13 @@ def test_run_dub_pipeline_end_to_end( assert "dubbing" in system["content"] assert "German" in system["content"] - # Synthesis: the translated text in the target language, every speaker on - # German's one native voice (the language selects the voice). + # Synthesis: the translated text in the target language, speakers rotating through + # the built-in voices (each speaks German; the language param picks the language). assert [(cfg.voice, cfg.text) for cfg in fake_synthesize] == [ - ("juergen", "DE:Hello."), - ("juergen", "DE:World."), + ("jane", "DE:Hello."), + ("michael", "DE:World."), ] - assert all(cfg.language == "German" for cfg in fake_synthesize) + assert all(cfg.query_params()["language"] == "german" for cfg in fake_synthesize) # The dubbed track: silence to 1.0 s, segment 1, silence to 3.0 s, segment 2, # then a tail pad out to the source's 5 s duration (rate 100 -> 200 bytes/s). @@ -144,7 +144,7 @@ def test_run_dub_pipeline_end_to_end( "language": "German", "transcript_id": "tr_dub", "utterances": 2, - "speakers": {"A": "juergen", "B": "juergen"}, + "speakers": {"A": "jane", "B": "michael"}, "sample_rate": SAMPLE_RATE, "audio_duration_seconds": 5.0, } @@ -164,7 +164,7 @@ def test_run_dub_human_summary( assert "dub.de.mp4" in out assert "dubbed to German" in out assert "2 utterances" in out - assert "A=juergen, B=juergen" in out + assert "A=jane, B=michael" in out def test_human_summary_escapes_user_controlled_markup( @@ -224,8 +224,8 @@ def test_voice_overrides_pin_speakers_without_consuming_rotation( ): opts = dataclasses.replace(DEFAULTS, media=str(media), voice=["A=mary"]) _run(opts, json_mode=True) - # A is pinned; B still takes German's native voice from the rotation. - assert [cfg.voice for cfg in fake_synthesize] == ["mary", "juergen"] + # A is pinned; B still takes the rotation's first voice (A's pin consumed nothing). + assert [cfg.voice for cfg in fake_synthesize] == ["mary", "jane"] # Every mapping applied -> no "Ignoring" warning fires. assert "Ignoring" not in capsys.readouterr().err diff --git a/tests/test_environments.py b/tests/test_environments.py index 1115b85e..a6366b3a 100644 --- a/tests/test_environments.py +++ b/tests/test_environments.py @@ -17,9 +17,12 @@ def test_production_uses_production_ams_endpoint(): assert env.ams_base == "https://ams.internal.assemblyai-labs.com" -def test_sync_base_per_environment(): - assert environments.get("production").sync_base == "https://sync.assemblyai.com" - assert environments.get("sandbox000").sync_base == "https://sync.sandbox000.assemblyai-labs.com" +def test_dictation_base_per_environment(): + assert environments.get("production").dictation_base == "https://dictation.assemblyai.com" + assert ( + environments.get("sandbox000").dictation_base + == "https://dictation.sandbox000.assemblyai-labs.com" + ) def test_get_unknown_raises_cli_error(): @@ -69,6 +72,5 @@ def test_sandbox_has_streaming_tts_host(): ) -def test_production_has_no_streaming_tts_host(): - # Empty host is the "TTS not available here" signal the speak command checks. - assert environments.get("production").streaming_tts_host == "" +def test_production_streaming_tts_host(): + assert environments.get("production").streaming_tts_host == "tts.us.assemblyai.com" diff --git a/tests/test_init_template_agent_cascade.py b/tests/test_init_template_agent_cascade.py index af40870a..009c78d0 100644 --- a/tests/test_init_template_agent_cascade.py +++ b/tests/test_init_template_agent_cascade.py @@ -52,17 +52,17 @@ def test_settings_reads_env(monkeypatch): assert settings.LLM_GATEWAY_URL == "https://llm.example/v1" -def test_settings_sandbox_defaults(monkeypatch): - # With the host env vars unset, the module falls back to the sandbox defaults - # (TTS is sandbox-only, so the whole cascade defaults there). Asserting the exact - # default strings keeps the mutation gate honest on settings.py's literals. +def test_settings_production_defaults(monkeypatch): + # With the host env vars unset, the module falls back to the production hosts. + # Asserting the exact default strings keeps the mutation gate honest on + # settings.py's literals. monkeypatch.delenv("ASSEMBLYAI_STREAMING_HOST", raising=False) monkeypatch.delenv("ASSEMBLYAI_TTS_HOST", raising=False) monkeypatch.delenv("ASSEMBLYAI_LLM_GATEWAY_URL", raising=False) settings = _load("api.settings", monkeypatch) - assert settings.STREAMING_HOST == "streaming.sandbox000.assemblyai-labs.com" - assert settings.TTS_HOST == "streaming-tts.sandbox000.assemblyai-labs.com" - assert settings.LLM_GATEWAY_URL == "https://llm-gateway.sandbox000.assemblyai-labs.com/v1" + assert settings.STREAMING_HOST == "streaming.assemblyai.com" + assert settings.TTS_HOST == "tts.us.assemblyai.com" + assert settings.LLM_GATEWAY_URL == "https://llm-gateway.assemblyai.com/v1" assert settings.SYSTEM_PROMPT == ( "You are a friendly, concise voice assistant. Keep replies short and conversational. " "Your reply is read aloud by a text-to-speech engine, so write plain spoken prose — " @@ -86,7 +86,10 @@ def test_unavailable_reason_missing_tts_host(monkeypatch: pytest.MonkeyPatch) -> settings.API_KEY = "sk-test" settings.TTS_HOST = "" reason = cascade.unavailable_reason(settings) - assert "sandbox" in reason and "assembly --sandbox init agent-cascade" in reason + assert reason == ( + "ASSEMBLYAI_TTS_HOST is empty — set it to your streaming-TTS host " + "(tts.us.assemblyai.com in production)." + ) def test_unavailable_reason_ok(monkeypatch: pytest.MonkeyPatch) -> None: @@ -117,9 +120,7 @@ def test_tts_url_carries_voice_and_rate(monkeypatch: pytest.MonkeyPatch) -> None settings.VOICE_ID = "aai-jane" settings.OUTPUT_SAMPLE_RATE = 24000 url = cascade.tts_url(settings) - assert url.startswith("wss://tts.example/v1/ws/?") - assert "voice_id=aai-jane" in url - assert "sample_rate=24000" in url + assert url == "wss://tts.example/v1/ws?voice_id=aai-jane&sample_rate=24000&encoding=pcm_s16le" def test_is_final_user_turn(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_init_template_agent_cascade_api.py b/tests/test_init_template_agent_cascade_api.py index 9a985aa2..371152d4 100644 --- a/tests/test_init_template_agent_cascade_api.py +++ b/tests/test_init_template_agent_cascade_api.py @@ -220,7 +220,7 @@ def test_synthesize_audio_frame_missing_payload_defaults_empty( cascade = _cascade(monkeypatch) browser = FakeBrowser() # An Audio frame with no `audio` key must yield reply.audio with data == "" (the default). - tts = FakeWS([{"type": "Begin", "configuration": {}}, {"type": "Audio", "is_final": True}]) + tts = FakeWS([{"type": "Begin", "configuration": {}}, {"type": "Audio"}, {"type": "FlushDone"}]) asyncio.run(cascade._synthesize(browser, tts, "hi")) assert {"type": "reply.audio", "data": ""} in browser.sent diff --git a/tests/test_init_template_agent_cascade_reply.py b/tests/test_init_template_agent_cascade_reply.py index 3bed84ce..a7baf0ca 100644 --- a/tests/test_init_template_agent_cascade_reply.py +++ b/tests/test_init_template_agent_cascade_reply.py @@ -30,7 +30,10 @@ def test_synthesize_streams_audio_frames(monkeypatch): [ {"type": "Begin", "configuration": {"sample_rate": 24000}}, {"type": "Audio", "audio": "AAA="}, - {"type": "Audio", "audio": "BBB=", "is_final": True}, + {"type": "Audio", "audio": "BBB="}, + {"type": "FlushDone"}, + # Past FlushDone the request is complete: nothing more may be forwarded. + {"type": "Audio", "audio": "CCC="}, ] ) asyncio.run(cascade._synthesize(browser, tts, "hi")) @@ -56,13 +59,13 @@ def test_synthesize_raises_on_error_frame(monkeypatch): def test_synthesize_raises_when_no_begin(monkeypatch): cascade = _cascade(monkeypatch) browser = FakeBrowser() - tts = FakeWS([{"type": "Audio", "audio": "AAA=", "is_final": True}]) + tts = FakeWS([{"type": "Audio", "audio": "AAA="}, {"type": "FlushDone"}]) with pytest.raises(RuntimeError, match="did not begin"): asyncio.run(cascade._synthesize(browser, tts, "hi")) def test_synthesize_handles_close_without_final_frame(monkeypatch): - # The TTS socket closes after some audio but before an is_final frame: the loop + # The TTS socket closes after some audio but before its FlushDone: the loop # must end cleanly (forward what arrived, then Terminate) instead of raising. cascade = _cascade(monkeypatch) browser = FakeBrowser() @@ -77,7 +80,11 @@ def test_speak_emits_done_on_success(monkeypatch): cascade, deps = _deps( monkeypatch, stt=FakeWS(), - tts_frames=[{"type": "Begin", "configuration": {}}, {"type": "Audio", "is_final": True}], + tts_frames=[ + {"type": "Begin", "configuration": {}}, + {"type": "Audio"}, + {"type": "FlushDone"}, + ], llm_text=[], ) browser = FakeBrowser() @@ -148,7 +155,8 @@ def test_generate_reply_speaks_llm_text(monkeypatch): stt=FakeWS(), tts_frames=[ {"type": "Begin", "configuration": {}}, - {"type": "Audio", "audio": "AAA=", "is_final": True}, + {"type": "Audio", "audio": "AAA="}, + {"type": "FlushDone"}, ], llm_text=["Hello", " world."], ) @@ -168,7 +176,8 @@ def test_generate_reply_streams_each_sentence(monkeypatch): stt=FakeWS(), tts_frames=[ {"type": "Begin", "configuration": {}}, - {"type": "Audio", "audio": "AAA=", "is_final": True}, + {"type": "Audio", "audio": "AAA="}, + {"type": "FlushDone"}, ], llm_text=["Hello there. ", "How are you?"], ) @@ -229,7 +238,9 @@ async def llm_stream(_messages): await asyncio.Event().wait() # then block until the barge-in cancels us async def connect_tts(): - return FakeWS([{"type": "Begin", "configuration": {}}, {"type": "Audio", "is_final": True}]) + return FakeWS( + [{"type": "Begin", "configuration": {}}, {"type": "Audio"}, {"type": "FlushDone"}] + ) deps = cascade.Deps( connect_stt=_async_return(FakeWS()), diff --git a/tests/test_list_voices.py b/tests/test_list_voices.py index 6060833b..957e5043 100644 --- a/tests/test_list_voices.py +++ b/tests/test_list_voices.py @@ -12,18 +12,17 @@ from aai_cli.core import config from aai_cli.main import app from aai_cli.tts import voice_list +from tests._tts_session_helpers import use_no_tts_production -# Sandbox-only commands run under --sandbox, which the root callback gates on an -# AssemblyAI login — run the whole module as an employee. +# Most cases run under --sandbox, which the root callback gates on an AssemblyAI +# login — run the whole module as an employee. pytestmark = pytest.mark.usefixtures("internal_profile") runner = CliRunner() COMMANDS = ["speak", "dub", "live"] -VOICES = [ - {"voice_id": "aai-jane", "name": "Jane", "language": "en", "tier": "preset"}, - {"voice_id": "aai-giovanni", "language": "it"}, -] +# The account's own voices, as GET /v1/voices returns them (abridged). +VOICES = [{"slug": "alex-6f2f2e", "name": "Alex", "language": "english", "visibility": "private"}] @pytest.fixture @@ -46,26 +45,34 @@ def test_lists_the_service_voices(command, fake_fetch): result = runner.invoke(app, ["--sandbox", command, "--list-voices"]) assert result.exit_code == 0, result.output assert fake_fetch["api_key"] == "test-key" - assert "aai-jane Jane · en" in result.output - assert "aai-giovanni it" in result.output + # The built-in voices first, then the account's own; slugs padded to the widest. + assert "aai-jane Jane · built-in, speaks every language" in result.output + assert "alex-6f2f2e Alex · english" in result.output @pytest.mark.parametrize("command", COMMANDS) def test_json_emits_the_raw_listing(command, fake_fetch): - # --json is honored even though it parses after the eager flag: the raw server - # records are dumped untouched (unknown fields like "tier" survive). + # --json is honored even though it parses after the eager flag: the server's + # records are dumped untouched (unknown fields like "visibility" survive). result = runner.invoke(app, ["--sandbox", command, "--list-voices", "--json"]) assert result.exit_code == 0, result.output - assert json.loads(result.stdout) == VOICES - assert "Jane · en" not in result.output + assert json.loads(result.stdout) == voice_list.builtin_voices() + VOICES + assert "Alex · english" not in result.output @pytest.mark.parametrize("command", COMMANDS) -def test_production_env_is_rejected_with_the_command_named(command, fake_fetch): +def test_lists_in_production(command, fake_fetch): result = runner.invoke(app, [command, "--list-voices"]) # default = production + assert result.exit_code == 0, result.output + assert "alex-6f2f2e Alex · english" in result.output + + +@pytest.mark.parametrize("command", COMMANDS) +def test_env_without_tts_host_is_rejected_with_the_command_named(command, fake_fetch, monkeypatch): + use_no_tts_production(monkeypatch) + result = runner.invoke(app, [command, "--list-voices"]) assert result.exit_code == 2 - assert "only available in the sandbox" in result.output - assert f"Re-run as: assembly --sandbox {command}" in result.output + assert f"assembly {command} needs streaming TTS" in " ".join(result.output.split()) assert "api_key" not in fake_fetch # refused before any request diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 54257a6c..3b5d269a 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -57,12 +57,11 @@ def test_known_models_is_the_full_gateway_list(): "gpt-oss-120b", "gpt-oss-20b", "gemini-3.5-flash", - "gemini-3-flash-preview", - "gemini-3.1-flash-lite-preview", + "gemini-3.1-flash-lite", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", - "kimi-k2.5", + "kimi-k3", "qwen3-next-80b-a3b", "qwen3-32B", ) diff --git a/tests/test_sandbox_access.py b/tests/test_sandbox_access.py index 6fe112de..3f322c9a 100644 --- a/tests/test_sandbox_access.py +++ b/tests/test_sandbox_access.py @@ -237,13 +237,13 @@ def test_help_hides_the_sandbox_surface_from_external_accounts_and_restores_it(m # would make the positive checks fail *and* the negative ones pass vacuously. This # is the same normalization the help-snapshot suite uses. See tests/AGENTS.md. external = normalize(runner.invoke(app, ["--help"]).output) - # Both the flags and the [sandbox]-tagged commands are gone for an external account. + # The sandbox flags are gone for an external account. assert "--sandbox" not in external assert "--env" not in external - assert "[sandbox]" not in external - # The [sandbox]-only `live` command's summary is hidden too (a token unique to it, - # since the bare word "live" also appears in other commands' descriptions). - assert "tool-using" not in external + # The streaming-TTS commands run in production now, so they're no longer + # [sandbox]-tagged: `live` stays visible (a token unique to its summary, since the + # bare word "live" also appears in other commands' descriptions). + assert "tool-using" in external # …but the filter is surgical: non-sandbox flags and commands stay visible (this # also kills the mutant that would treat every option/command as sandbox). assert "--profile" in external @@ -256,5 +256,4 @@ def test_help_hides_the_sandbox_surface_from_external_accounts_and_restores_it(m internal = normalize(runner.invoke(app, ["--help"]).output) assert "--sandbox" in internal assert "--env" in internal - assert "[sandbox]" in internal assert "tool-using" in internal diff --git a/tests/test_speak.py b/tests/test_speak.py index 36603254..a0b2cdbc 100644 --- a/tests/test_speak.py +++ b/tests/test_speak.py @@ -11,6 +11,7 @@ from aai_cli.core import config from aai_cli.main import app from aai_cli.tts import session +from tests._tts_session_helpers import use_no_tts_production # `speak` is sandbox-only and runs under `--sandbox`, which the root callback gates # on an AssemblyAI login — run the whole module as an employee. @@ -52,19 +53,18 @@ def _fake(api_key, cfg, *, connect=None, on_warning=None, on_audio=None): pcm=b"\x01\x02\x03\x04", sample_rate=24000, audio_duration_seconds=0.123456 ) - monkeypatch.setattr(session, "synthesize", _fake) + monkeypatch.setattr(session, "synthesize_chunked", _fake) return calls -def test_production_env_is_rejected_with_sandbox_hint(): - result = runner.invoke(app, ["speak", "Hello"]) # default = production +def test_env_without_tts_host_is_rejected(monkeypatch): + use_no_tts_production(monkeypatch) + result = runner.invoke(app, ["speak", "Hello"]) assert result.exit_code == 2 - assert "only available in the sandbox" in result.output - # The suggestion spells out the exact corrected invocation: --sandbox is a root - # flag, so it must go before the command, not after it. - assert "Re-run as: assembly --sandbox speak" in result.output - # Rich wraps the suggestion at 80 columns, so compare whitespace-normalized. - assert "before the command" in " ".join(result.output.split()) + # Rich wraps the error at 80 columns, so compare whitespace-normalized. + output = " ".join(result.output.split()) + assert "assembly speak needs streaming TTS" in output + assert "to run assembly speak in production" in output def test_plays_audio_by_default(monkeypatch): @@ -83,7 +83,7 @@ def _fake(api_key, cfg, *, connect=None, on_warning=None, on_audio=None): pcm=b"\x01\x02\x03\x04", sample_rate=24000, audio_duration_seconds=0.1 ) - monkeypatch.setattr(session, "synthesize", _fake) + monkeypatch.setattr(session, "synthesize_chunked", _fake) result = runner.invoke(app, ["--sandbox", "speak", "Hello there"]) assert result.exit_code == 0 # The frame reached the speaker (chunk + the server's reported rate), proving the @@ -105,7 +105,7 @@ def _fake(api_key, cfg, *, connect=None, on_warning=None, on_audio=None): on_warning("slow synthesis") return session.SpeakResult(pcm=b"", sample_rate=24000, audio_duration_seconds=0.0) - monkeypatch.setattr(session, "synthesize", _fake) + monkeypatch.setattr(session, "synthesize_chunked", _fake) result = runner.invoke(app, ["--sandbox", "speak", "Hi", "--json"]) assert result.exit_code == 0 warning = next(json.loads(line) for line in result.stderr.splitlines() if line.startswith("{")) @@ -147,10 +147,9 @@ def test_url_reads_web_page_aloud(monkeypatch, fake_synthesize): assert fake_synthesize["cfg"].text == "The article body." -def test_long_text_is_synthesized_one_chunk_per_connection(monkeypatch): - # _speak_single feeds PocketTTS one chunk at a time (one synthesize/connection each), - # never the whole document in a single Generate — the fix for the long --url/PDF case. - monkeypatch.setattr("aai_cli.tts.text.chunk_text", lambda _t: ["First.", "Second.", "Third."]) +def test_long_text_goes_through_the_chunked_session(monkeypatch): + # _speak_single hands the whole text to synthesize_chunked, which splits it into + # bounded requests on one session (see test_tts_session) — the long --url/PDF case. monkeypatch.setattr("aai_cli.commands.speak._exec.audio.PcmPlayer", lambda **_: _FakePlayer()) spoken: list[str] = [] @@ -158,11 +157,10 @@ def _fake(api_key, cfg, *, connect=None, on_warning=None, on_audio=None): spoken.append(cfg.text) return session.SpeakResult(pcm=b"\x01\x02", sample_rate=24000, audio_duration_seconds=0.0) - monkeypatch.setattr(session, "synthesize", _fake) + monkeypatch.setattr(session, "synthesize_chunked", _fake) result = runner.invoke(app, ["--sandbox", "speak", "First. Second. Third."]) assert result.exit_code == 0 - # One synthesize call per chunk, each carrying just that chunk's text. - assert spoken == ["First.", "Second.", "Third."] + assert spoken == ["First. Second. Third."] def test_url_and_text_argument_are_mutually_exclusive(monkeypatch): @@ -223,18 +221,21 @@ def test_voice_and_language_flow_into_config(monkeypatch, fake_synthesize): cfg = fake_synthesize["cfg"] assert cfg.voice == "jane" assert cfg.language == "English" - assert cfg.query_params() == {"voice_id": "aai-jane", "language": "English"} + assert cfg.query_params() == { + "voice_id": "aai-jane", + "language": "english", + "encoding": "pcm_s16le", + } -def test_default_voice_follows_the_language(monkeypatch, fake_synthesize): - # Each voice speaks one language: with no --voice, a non-English --language - # switches to that language's native voice instead of English "jane". +def test_default_voice_is_jane_in_every_language(monkeypatch, fake_synthesize): + # Every built-in voice speaks every language: --language only sets the language. monkeypatch.setattr("aai_cli.commands.speak._exec.audio.play_pcm", lambda *a, **k: None) - result = runner.invoke(app, ["--sandbox", "speak", "Ciao", "--language", "Italian"]) + result = runner.invoke(app, ["--sandbox", "speak", "Ciao", "--language", "it"]) assert result.exit_code == 0 cfg = fake_synthesize["cfg"] - assert cfg.voice == "giovanni" - assert cfg.language == "Italian" + assert cfg.voice == "jane" + assert cfg.query_params()["language"] == "italian" def test_explicit_voice_beats_the_language_default(monkeypatch, fake_synthesize): @@ -243,7 +244,7 @@ def test_explicit_voice_beats_the_language_default(monkeypatch, fake_synthesize) app, ["--sandbox", "speak", "Bonjour", "--voice", "jane", "--language", "French"] ) assert result.exit_code == 0 - # A chosen voice always wins; the language only drives the default. + # A chosen voice is sent as given, whatever the language. assert fake_synthesize["cfg"].voice == "jane" @@ -257,7 +258,7 @@ def test_json_mode_emits_metadata_object_on_stdout(monkeypatch): def _fake(api_key, cfg, *, connect=None, on_warning=None, on_audio=None): return session.SpeakResult(pcm=pcm, sample_rate=24000, audio_duration_seconds=0.12345833) - monkeypatch.setattr(session, "synthesize", _fake) + monkeypatch.setattr(session, "synthesize_chunked", _fake) result = runner.invoke(app, ["--sandbox", "speak", "Hi", "--voice", "jane", "--json"]) assert result.exit_code == 0 # The behavioral split: --json yields a parseable object, not human prose. @@ -307,13 +308,12 @@ def test_labeled_stdin_uses_dialogue_path_with_default_rotation(fake_dialogue): ] -def test_dialogue_rotation_follows_the_language(fake_dialogue): - # French has exactly one native voice, so every speaker switches to it — - # the language selects the voice in dialogue mode too. +def test_dialogue_rotation_is_the_same_in_every_language(fake_dialogue): + # Every built-in voice speaks French, so speakers still rotate through distinct voices. text = "Speaker A: Bonjour.\nSpeaker B: Salut." result = runner.invoke(app, ["--sandbox", "speak", "--language", "French"], input=text) assert result.exit_code == 0 - assert fake_dialogue["segments"] == [("estelle", "Bonjour."), ("estelle", "Salut.")] + assert fake_dialogue["segments"] == [("jane", "Bonjour."), ("michael", "Salut.")] assert fake_dialogue["language"] == "French" diff --git a/tests/test_sync_stt.py b/tests/test_sync_stt.py deleted file mode 100644 index 2364841e..00000000 --- a/tests/test_sync_stt.py +++ /dev/null @@ -1,222 +0,0 @@ -"""The Sync STT HTTP boundary: request shape, error normalization, parsing.""" - -import dataclasses - -import httpx2 as httpx -import pytest - -from aai_cli.core import environments, sync_stt -from aai_cli.core.errors import APIError, NotAuthenticated - - -def _patch_transport(monkeypatch, handler): - real_client = httpx.Client - seen_kwargs = {} - - def fake_client(*args, **kwargs): - seen_kwargs.update(kwargs) - kwargs["transport"] = httpx.MockTransport(handler) - return real_client(*args, **kwargs) - - monkeypatch.setattr(sync_stt.httpx, "Client", fake_client) - return seen_kwargs - - -def _ok_handler(seen): - def handler(request: httpx.Request) -> httpx.Response: - seen["url"] = str(request.url) - seen["auth"] = request.headers.get("authorization") - seen["model"] = request.headers.get("x-aai-model") - seen["body"] = request.read() - return httpx.Response( - 200, - json={ - "text": "Hi, I'm calling about my order.", - "confidence": 0.87, - "audio_duration_ms": 1500, - "session_id": "eb92c4ff", - }, - ) - - return handler - - -def test_posts_pcm_and_config_to_the_active_environment(monkeypatch): - seen = {} - client_kwargs = _patch_transport(monkeypatch, _ok_handler(seen)) - result = sync_stt.transcribe_pcm("sk_key_pcm", b"\x01\x02pcm-bytes", sample_rate=16000) - assert seen["url"] == "https://sync.assemblyai.com/transcribe" - assert seen["auth"] == "sk_key_pcm" - assert seen["model"] == "u3-sync-pro" - # Multipart body: a raw-PCM audio part plus the JSON config part. - assert b"\x01\x02pcm-bytes" in seen["body"] - assert b"audio/pcm" in seen["body"] - assert b'"sample_rate": 16000' in seen["body"] - assert b'"channels": 1' in seen["body"] - # Optional knobs are omitted entirely when unset. - assert b"language_code" not in seen["body"] - assert b"prompt" not in seen["body"] - assert b"word_boost" not in seen["body"] - # Generous timeout: the upload can carry up to 2 minutes of audio. - assert client_kwargs["timeout"] == 60.0 - assert result == sync_stt.SyncTranscript( - text="Hi, I'm calling about my order.", - confidence=0.87, - audio_duration_ms=1500, - session_id="eb92c4ff", - ) - - -def test_targets_the_sandbox_host_when_active(monkeypatch): - seen = {} - _patch_transport(monkeypatch, _ok_handler(seen)) - environments.set_active(environments.get("sandbox000")) - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert seen["url"] == "https://sync.sandbox000.assemblyai-labs.com/transcribe" - - -def test_optional_config_fields_are_sent_when_set(monkeypatch): - seen = {} - _patch_transport(monkeypatch, _ok_handler(seen)) - sync_stt.transcribe_pcm( - "sk", - b"pcm", - sample_rate=44100, - channels=2, - language_code=["en", "es"], - prompt="Transcribe verbatim.", - word_boost=["AssemblyAI", "LeMUR"], - ) - assert b'"sample_rate": 44100' in seen["body"] - assert b'"channels": 2' in seen["body"] - assert b'"language_code": ["en", "es"]' in seen["body"] - assert b'"prompt": "Transcribe verbatim."' in seen["body"] - assert b'"word_boost": ["AssemblyAI", "LeMUR"]' in seen["body"] - - -def test_single_language_code_is_sent_as_a_string(monkeypatch): - seen = {} - _patch_transport(monkeypatch, _ok_handler(seen)) - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000, language_code="es") - assert b'"language_code": "es"' in seen["body"] - - -def test_sync_transcript_is_immutable(monkeypatch): - seen = {} - _patch_transport(monkeypatch, _ok_handler(seen)) - result = sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - field_name = dataclasses.fields(result)[0].name - with pytest.raises(dataclasses.FrozenInstanceError): - setattr(result, field_name, "mutated") - - -def test_missing_optional_response_fields_parse_as_none(monkeypatch): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"text": "bare"}) - - _patch_transport(monkeypatch, handler) - result = sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert result == sync_stt.SyncTranscript( - text="bare", confidence=None, audio_duration_ms=None, session_id=None - ) - - -@pytest.mark.parametrize("status", [401, 403]) -def test_auth_rejection_raises_not_authenticated(monkeypatch, status): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(status, json={"detail": "Invalid API key"}) - - _patch_transport(monkeypatch, handler) - with pytest.raises(NotAuthenticated) as exc: - sync_stt.transcribe_pcm("bad", b"pcm", sample_rate=16000) - assert exc.value.rejected_key is True - - -@pytest.mark.parametrize("status", [429, 503]) -def test_rate_limit_and_capacity_are_retryable_api_errors(monkeypatch, status): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - status, json={"error_code": "capacity_exceeded", "message": "server at cap"} - ) - - _patch_transport(monkeypatch, handler) - with pytest.raises(APIError) as exc: - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert "busy" in exc.value.message - assert str(status) in exc.value.message - assert "server at cap (capacity_exceeded)" in exc.value.message - assert "try again" in (exc.value.suggestion or "") - - -def test_audio_error_carries_error_code_and_message(monkeypatch): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 400, json={"error_code": "audio_too_short", "message": "audio below 80 ms"} - ) - - _patch_transport(monkeypatch, handler) - with pytest.raises(APIError) as exc: - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert "Sync transcription failed (400)" in exc.value.message - assert "audio below 80 ms (audio_too_short)" in exc.value.message - - -def test_error_detail_reads_detail_field(monkeypatch): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(400, json={"detail": "missing audio part"}) - - _patch_transport(monkeypatch, handler) - with pytest.raises(APIError) as exc: - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert "missing audio part" in exc.value.message - - -@pytest.mark.parametrize( - ("body", "expected"), - [ - (b"upstream proxy says no", "upstream proxy says no"), # non-JSON body - (b"", "HTTP 500"), # empty body -> bare status - (b'["weird"]', "weird"), # JSON but not an object -> raw text - (b'{"unrelated": true}', '{"unrelated": true}'), # object without message/detail - ], -) -def test_error_detail_falls_back_to_raw_body_or_status(monkeypatch, body, expected): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(500, content=body) - - _patch_transport(monkeypatch, handler) - with pytest.raises(APIError) as exc: - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert expected in exc.value.message - - -def test_success_with_unparseable_body_is_a_clean_api_error(monkeypatch): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, content=b"not-json") - - _patch_transport(monkeypatch, handler) - with pytest.raises(APIError) as exc: - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert "not valid JSON" in exc.value.message - - -@pytest.mark.parametrize("payload", [{"words": []}, ["list"]]) -def test_success_without_transcript_text_is_a_clean_api_error(monkeypatch, payload): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=payload) - - _patch_transport(monkeypatch, handler) - with pytest.raises(APIError) as exc: - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert "unexpected response shape" in exc.value.message - - -def test_network_failure_is_a_clean_api_error(monkeypatch): - def handler(request: httpx.Request) -> httpx.Response: - raise httpx.ConnectError("connection refused") - - _patch_transport(monkeypatch, handler) - with pytest.raises(APIError) as exc: - sync_stt.transcribe_pcm("sk", b"pcm", sample_rate=16000) - assert "Could not reach the Sync API" in exc.value.message - assert "network" in (exc.value.suggestion or "") diff --git a/tests/test_tts_session.py b/tests/test_tts_session.py index e0a393ac..999bd6d2 100644 --- a/tests/test_tts_session.py +++ b/tests/test_tts_session.py @@ -9,11 +9,11 @@ from tests._tts_session_helpers import ( FakeWS, audio_chunk, - audio_frame, begin_frame, connect_returning, flush_done_frame, use_env, + use_no_tts_production, ) @@ -22,8 +22,13 @@ def test_is_available_true_in_sandbox(): assert session.is_available() is True -def test_is_available_false_in_production(): +def test_is_available_true_in_production(): use_env("production") + assert session.is_available() is True + + +def test_is_available_false_without_a_tts_host(monkeypatch): + use_no_tts_production(monkeypatch) assert session.is_available() is False @@ -31,24 +36,45 @@ def test_ws_url_includes_set_params_only(): use_env("sandbox000") cfg = session.SpeakConfig(text="hi", voice="jane", language="English") url = session.ws_url(cfg.query_params()) - assert url.startswith("wss://streaming-tts.sandbox000.assemblyai-labs.com/v1/ws/?") + assert url.startswith("wss://streaming-tts.sandbox000.assemblyai-labs.com/v1/ws?") # The voice rides as voice_id=aai-: the bare voice= param is retired server-side # (3006 invalid_parameter), so it must not be sent even alongside the new one. assert "voice_id=aai-jane" in url assert "voice=jane" not in url - assert "language=English" in url + # The service takes the lowercase English name of the language. + assert "language=english" in url assert "sample_rate" not in url + # The encoding the CLI decodes is pinned on every session. + assert "encoding=pcm_s16le" in url + + +def test_ws_url_is_the_documented_production_endpoint(): + use_env("production") + url = session.ws_url(session.SpeakConfig(text="hi", voice="jane").query_params()) + assert url == "wss://tts.us.assemblyai.com/v1/ws?voice_id=aai-jane&encoding=pcm_s16le" def test_ws_url_no_params_has_no_query_string(): use_env("sandbox000") - url = session.ws_url(session.SpeakConfig(text="hi").query_params()) - assert url == "wss://streaming-tts.sandbox000.assemblyai-labs.com/v1/ws/" + assert session.ws_url({}) == "wss://streaming-tts.sandbox000.assemblyai-labs.com/v1/ws" + + +def test_query_params_named_fields_override_extra(): + cfg = session.SpeakConfig( + text="hi", + language="de", + extra={"encoding": "pcm_mulaw", "language": "x", "word_boundaries": "false"}, + ) + assert cfg.query_params() == { + "word_boundaries": "false", + "language": "german", + "encoding": "pcm_s16le", + } def test_query_params_serializes_sample_rate_as_string(): cfg = session.SpeakConfig(text="hi", sample_rate=16000) - assert cfg.query_params() == {"sample_rate": "16000"} + assert cfg.query_params() == {"sample_rate": "16000", "encoding": "pcm_s16le"} def _set_attr(obj: object, name: str, value: object) -> None: @@ -75,8 +101,9 @@ def test_synthesize_drives_the_full_protocol(): ws = FakeWS( [ begin_frame(sample_rate=24000), - audio_frame(b"\x01\x02\x03\x04", final=False), - audio_frame(b"\x05\x06", final=True), + audio_chunk(b"\x01\x02\x03\x04"), + audio_chunk(b"\x05\x06"), + flush_done_frame(), ] ) cfg = session.SpeakConfig(text="hello", voice="jane") @@ -87,7 +114,7 @@ def test_synthesize_drives_the_full_protocol(): # not "ForceFlushTextBuffer", which the server rejects with a validation error. assert [m["type"] for m in ws.sent] == ["Generate", "Flush", "Terminate"] assert ws.sent[0]["text"] == "hello" - # Accumulates decoded PCM across chunks and stops on the is_final frame. + # Accumulates decoded PCM across chunks until the request's FlushDone. assert result.pcm == b"\x01\x02\x03\x04\x05\x06" # The sample rate comes from the Begin frame's configuration, not the Audio frames. assert result.sample_rate == 24000 @@ -107,11 +134,11 @@ def test_synthesize_drives_the_full_protocol(): assert captured["kwargs"]["ping_timeout"] is None -def test_synthesize_stops_on_flush_done_when_audio_omits_is_final(): - # The live server ends a synthesis with a FlushDone frame and never sets is_final - # on its Audio frames. Without handling FlushDone the loop blocks until the recv - # timeout (the audio is silently lost), so FlushDone must end collection and return - # every PCM byte gathered so far — then Terminate the session as usual. +def test_synthesize_stops_on_flush_done(): + # The server ends a request with a FlushDone frame (Audio frames carry no end + # marker). Without handling FlushDone the loop blocks until the recv timeout (the + # audio is silently lost), so FlushDone must end collection and return every PCM + # byte gathered so far — then Terminate the session as usual. ws = FakeWS( [ begin_frame(sample_rate=24000), @@ -153,7 +180,7 @@ def test_synthesize_streams_each_chunk_to_on_audio_as_it_arrives(): def test_synthesize_without_on_audio_still_returns_full_pcm(): # The callback is optional: omitting it must not change the buffered result. - ws = FakeWS([begin_frame(), audio_frame(b"\x01\x02", final=True)]) + ws = FakeWS([begin_frame(), audio_chunk(b"\x01\x02"), flush_done_frame()]) result = session.synthesize("k", session.SpeakConfig(text="hi"), connect=lambda *a, **k: ws) assert result.pcm == b"\x01\x02" @@ -161,7 +188,9 @@ def test_synthesize_without_on_audio_still_returns_full_pcm(): def test_synthesize_reads_sample_rate_from_begin_configuration(): # A non-default rate in the Begin frame flows into the result and its duration, # proving the rate is read from Begin.configuration rather than hardcoded. - ws = FakeWS([begin_frame(sample_rate=16000), audio_frame(b"\x01\x02\x03\x04", final=True)]) + ws = FakeWS( + [begin_frame(sample_rate=16000), audio_chunk(b"\x01\x02\x03\x04"), flush_done_frame()] + ) result = session.synthesize("k", session.SpeakConfig(text="hi"), connect=lambda *a, **k: ws) assert result.sample_rate == 16000 assert result.audio_duration_seconds == pytest.approx(4 / 2 / 16000) @@ -172,7 +201,8 @@ def test_synthesize_falls_back_to_default_rate_when_begin_omits_configuration(): ws = FakeWS( [ json.dumps({"type": "Begin", "id": "s", "expires_at": 1}), - audio_frame(b"\x01\x02", final=True), + audio_chunk(b"\x01\x02"), + flush_done_frame(), ] ) result = session.synthesize("k", session.SpeakConfig(text="hi"), connect=lambda *a, **k: ws) @@ -232,9 +262,10 @@ def test_synthesize_maps_error_frame_to_api_error(): def test_synthesize_bounds_every_recv_with_the_frame_timeout(): # Each frame wait is bounded (60s): an unbounded recv() would hang the command # forever if the server went silent mid-session. - ws = FakeWS([begin_frame(), audio_frame(b"\x01\x02", final=True)]) + ws = FakeWS([begin_frame(), audio_chunk(b"\x01\x02"), flush_done_frame()]) session.synthesize("k", session.SpeakConfig(text="hi"), connect=lambda *a, **k: ws) - assert ws.recv_timeouts == [60.0, 60.0] + # Begin, Audio, FlushDone, then the Termination that answers Terminate. + assert ws.recv_timeouts == [60.0, 60.0, 60.0, 60.0] def test_synthesize_maps_silent_server_to_clean_api_error(): @@ -267,7 +298,8 @@ def test_synthesize_invokes_on_warning_then_continues(): [ json.dumps({"type": "Begin", "id": "s", "expires_at": 1}), json.dumps({"type": "Warning", "warning_code": 1, "warning": "slow"}), - audio_frame(b"\x01\x02", final=True), + audio_chunk(b"\x01\x02"), + flush_done_frame(), ] ) result = session.synthesize( @@ -285,7 +317,8 @@ def test_synthesize_ignores_warning_without_callback(): [ json.dumps({"type": "Begin", "id": "s", "expires_at": 1}), json.dumps({"type": "Warning", "warning_code": 1, "warning": "slow"}), - audio_frame(b"\x01\x02", final=True), + audio_chunk(b"\x01\x02"), + flush_done_frame(), ] ) # No on_warning: the warning is silently skipped, not an error. @@ -402,59 +435,3 @@ def _fake_connect(url, **kwargs): assert captured["kwargs"]["additional_headers"] == {"Authorization": "k"} assert captured["kwargs"]["max_size"] == 4096 assert captured["kwargs"]["ping_timeout"] == 42.0 - - -def test_synthesize_chunked_synthesizes_each_chunk_on_its_own_connection(monkeypatch): - # PocketTTS is fed one chunk per connection — never the whole document in one Generate. - monkeypatch.setattr("aai_cli.tts.text.chunk_text", lambda _t: ["First chunk.", "Second chunk."]) - sockets = [ - FakeWS([begin_frame(), audio_frame(b"\x01\x02", final=True)]), - FakeWS([begin_frame(), audio_frame(b"\x03\x04", final=True)]), - ] - pool = list(sockets) - result = session.synthesize_chunked( - "k", session.SpeakConfig(text="ignored"), connect=lambda *a, **k: pool.pop(0) - ) - # One Generate per chunk, each on its own connection, in order. - assert [ws.sent[0]["text"] for ws in sockets] == ["First chunk.", "Second chunk."] - assert not pool # both connections were opened - # PCM is concatenated across the chunk connections, and the duration recomputed from it. - assert result.pcm == b"\x01\x02\x03\x04" - assert result.audio_duration_seconds == pytest.approx(4 / 2 / 24000) - - -def test_synthesize_chunked_streams_each_chunk_to_on_audio(monkeypatch): - # on_audio fires per chunk as it arrives (incremental playback), carrying each - # connection's reported sample rate, across all chunks. - monkeypatch.setattr("aai_cli.tts.text.chunk_text", lambda _t: ["a.", "b."]) - pool = [ - FakeWS([begin_frame(sample_rate=16000), audio_frame(b"\x01\x02", final=True)]), - FakeWS([begin_frame(sample_rate=16000), audio_frame(b"\x03\x04", final=True)]), - ] - streamed: list[tuple[bytes, int]] = [] - session.synthesize_chunked( - "k", - session.SpeakConfig(text="x"), - connect=lambda *a, **k: pool.pop(0), - on_audio=lambda chunk, rate: streamed.append((chunk, rate)), - ) - assert streamed == [(b"\x01\x02", 16000), (b"\x03\x04", 16000)] - - -def test_synthesize_chunked_single_chunk_passes_text_through(): - # A short input is one chunk: the original text is synthesized unchanged. - captured: dict = {} - ws = FakeWS([begin_frame(), audio_frame(b"\x01\x02", final=True)]) - session.synthesize_chunked( - "k", session.SpeakConfig(text="Just one sentence."), connect=connect_returning(ws, captured) - ) - assert ws.sent[0]["text"] == "Just one sentence." - - -def test_synthesize_chunked_empty_text_returns_silent_default(): - # Whitespace-only text yields no chunks -> no audio at the default rate, and no crash. - # connect is omitted entirely: the loop never runs, so no connection is attempted. - result = session.synthesize_chunked("k", session.SpeakConfig(text=" ")) - assert result.pcm == b"" - assert result.sample_rate == 24000 - assert result.audio_duration_seconds == 0.0 diff --git a/tests/test_tts_session_dialogue.py b/tests/test_tts_session_dialogue.py index 249f1d10..8943504d 100644 --- a/tests/test_tts_session_dialogue.py +++ b/tests/test_tts_session_dialogue.py @@ -3,14 +3,14 @@ import pytest from aai_cli.tts import session -from tests._tts_session_helpers import FakeWS, audio_frame, begin_frame +from tests._tts_session_helpers import FakeWS, audio_chunk, begin_frame, flush_done_frame def test_synthesize_dialogue_concatenates_segments_with_silence(): # One fresh fake socket per segment; record the voice each connection requested. sockets = [ - FakeWS([begin_frame(sample_rate=24000), audio_frame(b"\xaa\xbb", final=True)]), - FakeWS([begin_frame(sample_rate=24000), audio_frame(b"\xcc\xdd", final=True)]), + FakeWS([begin_frame(sample_rate=24000), audio_chunk(b"\xaa\xbb"), flush_done_frame()]), + FakeWS([begin_frame(sample_rate=24000), audio_chunk(b"\xcc\xdd"), flush_done_frame()]), ] urls: list[str] = [] @@ -37,7 +37,7 @@ def _connect(url: str, **_kwargs): def test_synthesize_dialogue_single_segment_has_no_silence(): - ws = FakeWS([begin_frame(sample_rate=24000), audio_frame(b"\x01\x02", final=True)]) + ws = FakeWS([begin_frame(sample_rate=24000), audio_chunk(b"\x01\x02"), flush_done_frame()]) result = session.synthesize_dialogue("k", [("jane", "Hi.")], connect=lambda *a, **k: ws) assert result.pcm == b"\x01\x02" # no leading/trailing pad @@ -45,7 +45,7 @@ def test_synthesize_dialogue_single_segment_has_no_silence(): def test_synthesize_dialogue_uses_server_sample_rate(): # A non-default server rate must flow into the result (proving the per-segment # rate is read, not left at the default) and into the duration denominator. - ws = FakeWS([begin_frame(sample_rate=16000), audio_frame(b"\x01\x02", final=True)]) + ws = FakeWS([begin_frame(sample_rate=16000), audio_chunk(b"\x01\x02"), flush_done_frame()]) result = session.synthesize_dialogue("k", [("jane", "Hi.")], connect=lambda *a, **k: ws) assert result.sample_rate == 16000 assert result.audio_duration_seconds == pytest.approx(2 / 2 / 16000) diff --git a/tests/test_tts_session_multi.py b/tests/test_tts_session_multi.py new file mode 100644 index 00000000..0d3757a3 --- /dev/null +++ b/tests/test_tts_session_multi.py @@ -0,0 +1,165 @@ +"""Multi-request streaming-TTS sessions (aai_cli/tts/session.py): long text sent as +bounded Generate + Flush requests on one socket, unknown frames skipped, and the +documented Terminate -> Termination end of session. Split from test_tts_session.py +to keep both under the 500-line file gate.""" + +from __future__ import annotations + +import json + +import pytest + +from aai_cli.core.errors import APIError +from aai_cli.tts import session +from tests._tts_session_helpers import ( + FakeWS, + audio_chunk, + begin_frame, + connect_returning, + flush_done_frame, +) + + +def test_synthesize_chunked_sends_every_chunk_as_a_request_on_one_session(monkeypatch): + # The documented multi-request pattern: one socket, a Generate + Flush per chunk, + # never the whole document in one Generate nor a socket per chunk (3009). + monkeypatch.setattr("aai_cli.tts.text.chunk_text", lambda _t: ["First chunk.", "Second chunk."]) + ws = FakeWS( + [ + begin_frame(), + audio_chunk(b"\x01\x02"), + flush_done_frame(), + audio_chunk(b"\x03\x04"), + flush_done_frame(), + ] + ) + opened: list[str] = [] + result = session.synthesize_chunked( + "k", + session.SpeakConfig(text="ignored"), + connect=lambda url, **k: opened.append(url) or ws, + ) + assert len(opened) == 1 + assert ws.sent == [ + {"type": "Generate", "text": "First chunk."}, + {"type": "Flush"}, + {"type": "Generate", "text": "Second chunk."}, + {"type": "Flush"}, + {"type": "Terminate"}, + ] + # PCM is concatenated across the requests, and the duration recomputed from it. + assert result.pcm == b"\x01\x02\x03\x04" + assert result.audio_duration_seconds == pytest.approx(4 / 2 / 24000) + + +def test_synthesize_bounds_requests_in_flight(monkeypatch): + # At most three requests go out ahead of their FlushDone; the fourth waits for the + # first FlushDone, so a long document never arrives as one burst (3010). + monkeypatch.setattr("aai_cli.tts.text.chunk_text", lambda _t: ["a.", "b.", "c.", "d."]) + sent_before_first_done: list[int] = [] + done_before_terminate: list[int] = [] + + class _Watching(FakeWS): + done = 0 + + def recv(self, timeout: float | None = None) -> str: + frame = super().recv(timeout) + if json.loads(frame)["type"] == "FlushDone": + if not self.done: + sent_before_first_done.append(len(self.sent)) + self.done += 1 + return frame + + def send(self, data: str) -> None: + if json.loads(data)["type"] == "Terminate": + done_before_terminate.append(self.done) + super().send(data) + + ws = _Watching([begin_frame(), *[flush_done_frame()] * 4]) + session.synthesize_chunked("k", session.SpeakConfig(text="x"), connect=lambda *a, **k: ws) + assert sent_before_first_done == [6] # three Generate + Flush pairs + # Terminate waits until every request's FlushDone has arrived. + assert done_before_terminate == [4] + assert [m.get("text") for m in ws.sent if m["type"] == "Generate"] == ["a.", "b.", "c.", "d."] + + +def test_synthesize_chunked_streams_each_chunk_to_on_audio(monkeypatch): + # on_audio fires per chunk as it arrives (incremental playback), carrying the + # session's reported sample rate, across all requests. + monkeypatch.setattr("aai_cli.tts.text.chunk_text", lambda _t: ["a.", "b."]) + ws = FakeWS( + [ + begin_frame(sample_rate=16000), + audio_chunk(b"\x01\x02"), + flush_done_frame(), + audio_chunk(b"\x03\x04"), + flush_done_frame(), + ] + ) + streamed: list[tuple[bytes, int]] = [] + session.synthesize_chunked( + "k", + session.SpeakConfig(text="x"), + connect=lambda *a, **k: ws, + on_audio=lambda chunk, rate: streamed.append((chunk, rate)), + ) + assert streamed == [(b"\x01\x02", 16000), (b"\x03\x04", 16000)] + + +def test_synthesize_ignores_unknown_frames(): + # The protocol may add frame types (WordBoundaries today); they must be skipped. + ws = FakeWS( + [ + begin_frame(), + audio_chunk(b"\x01\x02"), + flush_done_frame(), + json.dumps({"type": "WordBoundaries", "flush_id": 0, "words": []}), + ] + ) + result = session.synthesize("k", session.SpeakConfig(text="hi"), connect=lambda *a, **k: ws) + assert result.pcm == b"\x01\x02" + + +def test_terminate_waits_for_termination_skipping_late_frames(): + # After Terminate, frames like a late WordBoundaries arrive before Termination. + ws = FakeWS( + [ + begin_frame(), + flush_done_frame(), + json.dumps({"type": "WordBoundaries", "flush_id": 0, "words": []}), + json.dumps({"type": "Termination"}), + ] + ) + session.synthesize("k", session.SpeakConfig(text="hi"), connect=lambda *a, **k: ws) + assert ws.recv_timeouts == [60.0, 60.0, 60.0, 60.0] + + +def test_error_after_terminate_is_an_api_error(): + ws = FakeWS( + [ + begin_frame(), + flush_done_frame(), + json.dumps({"type": "Error", "error_code": 3005, "error": "at capacity"}), + ] + ) + with pytest.raises(APIError, match=r"TTS error \(3005\): at capacity"): + session.synthesize("k", session.SpeakConfig(text="hi"), connect=lambda *a, **k: ws) + + +def test_synthesize_chunked_single_chunk_passes_text_through(): + # A short input is one chunk: the original text is synthesized unchanged. + captured: dict = {} + ws = FakeWS([begin_frame(), audio_chunk(b"\x01\x02"), flush_done_frame()]) + session.synthesize_chunked( + "k", session.SpeakConfig(text="Just one sentence."), connect=connect_returning(ws, captured) + ) + assert ws.sent[0]["text"] == "Just one sentence." + + +def test_synthesize_chunked_empty_text_returns_silent_default(): + # Whitespace-only text yields no chunks -> no audio at the default rate, and no crash. + # connect is omitted entirely: the loop never runs, so no connection is attempted. + result = session.synthesize_chunked("k", session.SpeakConfig(text=" ")) + assert result.pcm == b"" + assert result.sample_rate == 24000 + assert result.audio_duration_seconds == 0.0 diff --git a/tests/test_tts_voice_list.py b/tests/test_tts_voice_list.py index 5ab121d4..42be0b51 100644 --- a/tests/test_tts_voice_list.py +++ b/tests/test_tts_voice_list.py @@ -1,6 +1,6 @@ -"""The streaming-TTS voice listing boundary (aai_cli/tts/voice_list.py): the -GET /v1/voices request shape, error normalization, response parsing, and the -human rendering.""" +"""The streaming-TTS voice listing (aai_cli/tts/voice_list.py): the built-in voices +plus the account's own from GET /v1/voices — the request shape, pagination, error +normalization, response parsing, and the human rendering.""" from __future__ import annotations @@ -9,11 +9,12 @@ from aai_cli.core import environments from aai_cli.core.errors import APIError, NotAuthenticated -from aai_cli.tts import voice_list +from aai_cli.tts import voice_list, voices -VOICES = [ - {"voice_id": "aai-jane", "name": "Jane", "language": "en"}, - {"voice_id": "aai-giovanni", "name": "Giovanni", "language": "it"}, +# Records in the documented shape (abridged): the slug is the voice_id to send. +OWN = [ + {"id": "01a1", "slug": "alex-6f2f2e", "name": "Alex", "language": "english"}, + {"id": "01a2", "slug": "bea-12ab34", "name": "Bea", "language": "spanish"}, ] @@ -49,28 +50,48 @@ def handler(request: httpx.Request) -> httpx.Response: return handler, seen +def _page(listing, *, cursor=None): + return {"voices": listing, "has_more": cursor is not None, "next_cursor": cursor} + + def test_voices_url_is_the_active_tts_host(): assert voice_list.voices_url() == ( "https://streaming-tts.sandbox000.assemblyai-labs.com/v1/voices" ) -def test_fetch_gets_the_listing_with_the_raw_api_key(monkeypatch): - handler, seen = _respond(200, json=VOICES) +def test_fetch_gets_the_account_voices_with_the_raw_api_key(monkeypatch): + handler, seen = _respond(200, json=_page(OWN)) client_kwargs = _patch_transport(monkeypatch, handler) - assert voice_list.fetch_voices("sk_voices") == VOICES + assert voice_list.fetch_voices("sk_voices") == OWN assert seen["method"] == "GET" - assert seen["url"] == "https://streaming-tts.sandbox000.assemblyai-labs.com/v1/voices" + # The default scope (the account's own voices), at the largest page size. + assert seen["url"] == ( + "https://streaming-tts.sandbox000.assemblyai-labs.com/v1/voices?limit=100" + ) # Streaming TTS takes the raw key, not a Bearer token — same as its socket. assert seen["auth"] == "sk_voices" assert client_kwargs["timeout"] == 15.0 -def test_fetch_unwraps_an_object_envelope(monkeypatch): - # A {"voices": [...]} envelope yields the same list as a bare array. - handler, _ = _respond(200, json={"voices": VOICES}) +def test_fetch_follows_next_cursor_across_pages(monkeypatch): + urls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + urls.append(str(request.url)) + if "cursor" in request.url.params: + return httpx.Response(200, json=_page(OWN[1:])) + return httpx.Response(200, json=_page(OWN[:1], cursor="c1.next")) + _patch_transport(monkeypatch, handler) - assert voice_list.fetch_voices("k") == VOICES + assert voice_list.fetch_voices("k") == OWN + assert urls[1].endswith("/v1/voices?limit=100&cursor=c1.next") + + +def test_fetch_stops_when_has_more_is_false_even_with_a_cursor(monkeypatch): + handler, _ = _respond(200, json={"voices": OWN, "has_more": False, "next_cursor": "c"}) + _patch_transport(monkeypatch, handler) + assert voice_list.fetch_voices("k") == OWN @pytest.mark.parametrize("status", [401, 403]) @@ -114,9 +135,10 @@ def test_non_json_body_is_an_api_error(monkeypatch): @pytest.mark.parametrize( "body", [ - {"items": VOICES}, # an envelope under the wrong key + {"items": OWN}, # an envelope under the wrong key {"voices": "jane"}, # the right key, not a list - ["jane", "michael"], # a list, but of strings + {"voices": ["jane", "michael"]}, # a list, but of strings + OWN, # a bare array, not the documented envelope "jane", ], ) @@ -127,22 +149,39 @@ def test_wrong_shape_is_an_api_error(monkeypatch, body): voice_list.fetch_voices("k") -def test_format_pads_ids_and_joins_the_known_details(): +def test_builtin_voices_are_the_catalog_as_records(): + listing = voice_list.builtin_voices() + assert [voice["slug"] for voice in listing] == [f"aai-{name}" for name in voices.VOICES] + assert listing[5] == { + "slug": "aai-jane", + "name": "Jane", + "description": "built-in, speaks every language", + } + + +def test_list_voices_is_builtins_then_the_account_voices(monkeypatch): + seen: list[str] = [] + monkeypatch.setattr(voice_list, "fetch_voices", lambda key: seen.append(key) or OWN) + assert voice_list.list_voices("k") == voice_list.builtin_voices() + OWN + assert seen == ["k"] + + +def test_format_pads_slugs_and_joins_the_known_details(): listing: list[dict[str, object]] = [ - {"voice_id": "aai-jane", "name": "Jane", "language": "en", "description": "warm"}, - {"voice_id": "aai-giovanni", "language": "it", "extra": "ignored"}, + {"slug": "aai-jane", "name": "Jane", "language": "english", "description": "warm"}, + {"slug": "bea-12ab34", "language": "spanish", "extra": "ignored"}, ] - # Ids are left-aligned to the widest one; details join with " · " in a fixed + # Slugs are left-aligned to the widest one; details join with " · " in a fixed # order (name, language, description), skipping absent/empty fields and # unknown keys. Trailing whitespace is trimmed on a details-less line. assert voice_list.format_voice_list(listing) == ( - "aai-jane Jane · en · warm\naai-giovanni it" + "aai-jane Jane · english · warm\nbea-12ab34 spanish" ) -def test_format_without_details_or_id_is_still_one_line_per_voice(): - # A record missing its voice_id renders a "?" placeholder; an empty detail is skipped. - assert voice_list.format_voice_list([{"voice_id": "aai-x"}, {"name": ""}]) == "aai-x\n?" +def test_format_without_details_or_slug_is_still_one_line_per_voice(): + # A record missing its slug renders a "?" placeholder; an empty detail is skipped. + assert voice_list.format_voice_list([{"slug": "aai-x"}, {"name": ""}]) == "aai-x\n?" def test_format_empty_listing(): diff --git a/tests/test_tts_voices.py b/tests/test_tts_voices.py index 50518a87..c63bb622 100644 --- a/tests/test_tts_voices.py +++ b/tests/test_tts_voices.py @@ -1,6 +1,6 @@ -"""Tests for the streaming-TTS voice catalog (aai_cli/tts/voices.py): the -voice -> language mapping and the language-driven default-voice selection -`assembly speak` and `assembly dub` share.""" +"""Tests for the streaming-TTS voice catalog (aai_cli/tts/voices.py): the built-in +voices (each speaks every language), the language names the `language` connect +parameter takes, and the voice_id mapping `speak`, `dub`, and `live` share.""" from __future__ import annotations @@ -9,51 +9,39 @@ from aai_cli.tts import voices -def test_voice_languages_catalog(): - # An independent copy of the expected catalog: a silently edited entry in - # the shipped map must fail here, not just round-trip through itself. - assert voices.VOICE_LANGUAGES == { - "alba": "en", - "anna": "en", - "azelma": "en", - "bill_boerst": "en", - "caro_davy": "en", - "charles": "en", - "cosette": "en", - "eponine": "en", - "estelle": "fr", - "eve": "en", - "fantine": "en", - "george": "en", - "giovanni": "it", - "jane": "en", - "javert": "en", - "jean": "en", - "juergen": "de", - "lola": "es", - "marius": "en", - "mary": "en", - "michael": "en", - "paul": "en", - "peter_yearsley": "en", - "rafael": "pt", - "stuart_bell": "en", - "vera": "en", +def test_builtin_voices_are_the_documented_set(): + # An independent copy of the documented built-in voices (/docs/tts/voices): a + # silently edited entry in the shipped tuple must fail here. + assert voices.VOICES == ( + "alba", + "anna", + "charles", + "eve", + "george", + "jane", + "jean", + "mary", + "michael", + "paul", + "vera", + ) + + +def test_languages_are_the_documented_set(): + assert voices.LANGUAGES == { + "de": "german", + "en": "english", + "es": "spanish", + "fr": "french", + "it": "italian", + "pt": "portuguese", } -def test_english_rotation_is_the_confirmed_working_voices(): - assert voices.ENGLISH_ROTATION == ("jane", "michael", "mary", "paul", "eve", "george") - # Every rotation voice must actually speak English. - assert all(voices.VOICE_LANGUAGES[voice] == "en" for voice in voices.ENGLISH_ROTATION) - - -def test_every_voice_language_has_a_name(): - # rotation_for relies on this: any code a catalog voice speaks must - # normalize through language_code, so a rotation can never come back empty. - for code in set(voices.VOICE_LANGUAGES.values()): - assert voices.language_code(code) == code - assert voices.rotation_for(code) +def test_defaults_are_builtin_voices(): + assert voices.DEFAULT_VOICE == "jane" + assert voices.DEFAULT_ROTATION == ("jane", "michael", "mary", "paul", "eve", "george") + assert set(voices.DEFAULT_ROTATION) <= set(voices.VOICES) @pytest.mark.parametrize( @@ -65,8 +53,8 @@ def test_every_voice_language_has_a_name(): ("english", "en"), ("Portuguese", "pt"), (None, None), # no language requested - ("Klingon", None), # unknown language -> no catalog voices - ("ja", None), # a translatable language without a voice + ("Klingon", None), # a language the service doesn't speak + ("ja", None), ], ) def test_language_code(value, expected): @@ -74,37 +62,27 @@ def test_language_code(value, expected): @pytest.mark.parametrize( - ("language", "expected"), + ("value", "expected"), [ - ("English", voices.ENGLISH_ROTATION), - ("en", voices.ENGLISH_ROTATION), - (None, voices.ENGLISH_ROTATION), # server-default language -> English voices - ("Japanese", voices.ENGLISH_ROTATION), # no native voice -> English fallback - ("Italian", ("giovanni",)), - ("es", ("lola",)), - ("German", ("juergen",)), - ("pt", ("rafael",)), - ("French", ("estelle",)), + ("de", "german"), # the service takes the English name, not the code + ("German", "german"), # names are lowercased + (" FR ", "french"), + ("english", "english"), + ("Japanese", "japanese"), # unsupported: passes through for the server to refuse ], ) -def test_rotation_for(language, expected): - assert voices.rotation_for(language) == expected - - -@pytest.mark.parametrize( - ("language", "expected"), - [("English", "jane"), ("Italian", "giovanni"), ("fr", "estelle"), ("Klingon", "jane")], -) -def test_default_voice(language, expected): - assert voices.default_voice(language) == expected +def test_language_param(value, expected): + assert voices.language_param(value) == expected @pytest.mark.parametrize( ("voice", "expected"), [ - ("jane", "aai-jane"), # a catalog name gets the service's id prefix - ("bill_boerst", "aai-bill_boerst"), - ("aai-jane", "aai-jane"), # an id copied from --list-voices passes through + ("jane", "aai-jane"), # a built-in name gets its library slug + ("vera", "aai-vera"), + ("aai-jane", "aai-jane"), # a slug copied from --list-voices passes through + ("alex-6f2f2e", "alex-6f2f2e"), # an account (cloned) voice slug is sent as-is + ("01a160a3-8ebb-784f-92b0-6927026f2f2e", "01a160a3-8ebb-784f-92b0-6927026f2f2e"), ], ) def test_voice_id(voice, expected): From 6329ebb5392cd07f32398bda986ca7a3db2d10f7 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 22 Oct 2026 13:00:01 -0700 Subject: [PATCH 3/3] Bump vulnerable dependencies to resolve all Dependabot alerts Upgrades only the 16 locked packages with open alerts, each to (at least) its first patched release: aiohttp 3.14.5, anyio 4.14.2, cryptography 50.0.2, httpcore2/httpx2 2.13.1, langgraph-sdk 0.4.7, langsmith 0.14.9, mcp 1.30.0, pyasn1 0.6.4, pydantic-settings 2.16.0, pyjwt 2.15.1, pypdf 6.20.1, starlette 1.8.0, urllib3 2.8.0, virtualenv 21.14.5, yt-dlp 2026.8.19. mcp is held on the patched 1.x line (1.30.0) rather than jumping to 2.x: langchain-mcp-adapters, which backs `live --mcp-config`, doesn't bound it. yt-dlp's parse_duration now returns an int for whole seconds, so _section_timestamp coerces to float to keep its declared contract (and the `--show-code` download_range_func rendering) stable across versions. Co-Authored-By: Claude Opus 5.5 --- aai_cli/core/youtube.py | 3 + uv.lock | 662 ++++++++++++++++++++++++---------------- 2 files changed, 403 insertions(+), 262 deletions(-) diff --git a/aai_cli/core/youtube.py b/aai_cli/core/youtube.py index 3a956e7f..95644d1b 100644 --- a/aai_cli/core/youtube.py +++ b/aai_cli/core/youtube.py @@ -100,6 +100,9 @@ def _section_timestamp(value: str, sign: str | None) -> float: f'Invalid --download-sections time "{value}".', suggestion='Use the form "*start-end", e.g. "*0:00-5:00".', ) + # Newer yt-dlp returns an int for whole seconds ("5:00" -> 300); keep the float + # contract so ranges (and the --show-code rendering of them) don't vary by version. + seconds = float(seconds) return -seconds if sign else seconds diff --git a/uv.lock b/uv.lock index 24e1982d..824dd966 100644 --- a/uv.lock +++ b/uv.lock @@ -159,7 +159,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -171,90 +171,132 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" }, + { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" }, + { url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" }, + { url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" }, + { url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" }, + { url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" }, + { url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" }, + { url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" }, + { url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" }, + { url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" }, + { url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" }, + { url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/06d89f58b2db3ee92dd377217659d587e06f973e57bf0a97a0d8a4586c0c/aiohttp-3.14.5-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:788ecaa9c10533b786ce5ba70c4f2df78ad41819fd00a6c99d92b66f9a32e1da", size = 540674, upload-time = "2026-10-11T01:03:17.483Z" }, + { url = "https://files.pythonhosted.org/packages/18/39/5e822e038f496f0540ada91e27099d48f9e7f919b6c6deb4cf6db36cc706/aiohttp-3.14.5-cp315-cp315-android_24_x86_64.whl", hash = "sha256:5c76f1802bab718a68ac3cce447160605c734551f95c67ae90fa1132b215cb29", size = 554183, upload-time = "2026-10-11T01:03:19.71Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/0cf2619d902b5b160762422a7e5e02091295766a7fe4fcf5b9a655e386c1/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a6d02b4c38de03d9c7617813433e6a0fb6b522797974177d69d9dad431900833", size = 523160, upload-time = "2026-10-11T01:03:22.193Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/3553abfc53a40849dbaacc3f54c730ec58410809ffa2d05885ae56108f2d/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:20726f9782d5c2744c1c66255842d1d163bb3edcf768b8de25216bf47f7b6ccf", size = 530311, upload-time = "2026-10-11T01:03:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a4/5d25f73754bc1e8f983ba704e86d641aea290c967f195f2aeebdaed2bd84/aiohttp-3.14.5-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:248d779ad720b49d4fb355720e60c9e5f444f95887bc16974fea48fc56c41789", size = 540274, upload-time = "2026-10-11T01:03:26.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/a4/07eda5db2e3ee017d9590f36c12a94ec6f1f50516e8df78672373dfc7185/aiohttp-3.14.5-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a8ea271867e360ac985ae607f4a23ad9a38414b9aca1d49ec98839ae660e49f", size = 819239, upload-time = "2026-10-11T01:03:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/a8723dd987a696dd48d4cf2f0088e589ce77caebff0b96f2a78c20424380/aiohttp-3.14.5-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:823c910f046f23f4c713b8d99a2242dc65f591cb45ee86418fa11762a3c2963c", size = 549832, upload-time = "2026-10-11T01:03:32.02Z" }, + { url = "https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14", size = 549234, upload-time = "2026-10-11T01:03:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/8e3e07fd8a0d33d06955ff4e54a1cb92f4bce347ff55e441dc3e25a7b5e5/aiohttp-3.14.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3112585250b199296c26ca6e0131640b6a8d01bab8b232d2eb3763ed469de11", size = 1891314, upload-time = "2026-10-11T01:03:36.899Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b3/05a79ce2e25f024e93de30c94f39dc6aa6e2bc1e9c531a5c4b18dc61b7a4/aiohttp-3.14.5-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c147451b4a58e7050f7f7394e6c467867c84161560001f9ad4fb2d1446743946", size = 1884372, upload-time = "2026-10-11T01:03:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/94/52/0fd8af0717db109eea258191b326b5cb5847fb88928bfa6c862fd78b9ad3/aiohttp-3.14.5-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bf163cc701f3d4ac43ba7d97771bf5fd955220ef5500ef3ee847bc0ecfbf4ec1", size = 1930957, upload-time = "2026-10-11T01:03:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b3/fa78733da88812bf9fb193913fb0ce1548f8b6912047633fdf88a758ff8c/aiohttp-3.14.5-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d40ce41991e9d56fab4f5dc4a51fe59bc3b5c77c27f4b148963064d00232e8", size = 2038886, upload-time = "2026-10-11T01:03:44.646Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c", size = 1894470, upload-time = "2026-10-11T01:03:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/62/2a/f87feb42abe8e6a7c03814dbcb711540849a1e90aa392055f3183c643610/aiohttp-3.14.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50983e3be33d8c0942ab88cec3905b10602f64c469b20153c48c5d4e558dd016", size = 1716273, upload-time = "2026-10-11T01:03:50.121Z" }, + { url = "https://files.pythonhosted.org/packages/81/b2/adf1f960dd977722ed1347d33da512a1624807114f57a3b91f5cc828e081/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:16c8abd5bca220a47efe667d26f8460124c81810787e79ee87b242677563d9dd", size = 1857513, upload-time = "2026-10-11T01:03:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/ef8d910e641de4160026a513ace5888b7f92826bbc3fd90ced05d55a828e/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0790ec66fa4013e83c53b9025a45d454723da1a2fce28b3208c9b32d08af162f", size = 1887704, upload-time = "2026-10-11T01:03:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/7e/db/6c9142f941cba8d35be8e1fae6ea2bd390e754fc14076b8147aee1a4592f/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:cb11a971a3aea10f9b8373be628f1df932964fc6c6b174516d318a48c3ac4412", size = 1895684, upload-time = "2026-10-11T01:03:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7c/6a6bd9a72e576d376c668333b00c51c6147aba4fb863ed6c94996d497eca/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:932ce7e694bbc29b2bf6f64f2343c27d148d4997c771d01bdade4639b6749ff4", size = 1706299, upload-time = "2026-10-11T01:04:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/69/ec/d2cc494242f8c4d3d1cd70baf591742818a74386dc3b84af3195c5c4fced/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a7d470cf7b206e6359fc77b1b860632fde400d5a2ed59cd0181b93a686bc81ee", size = 1916645, upload-time = "2026-10-11T01:04:03.892Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6e/e852c53647e815db09a1b6b5ab634f54bd736412397e11940feef4d2c89a/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6e1d8637cf73eebc92eba2e11d4cfff98a3b562f2505bd75bba766d908926e8d", size = 1875408, upload-time = "2026-10-11T01:04:06.922Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f6/72ab6ef20c332399be593bac543d2c24a6d241e39d3540ce9f95a63e4bd2/aiohttp-3.14.5-cp315-cp315-win32.whl", hash = "sha256:fbdc5ec49f9ca3cd24955cf3520b10a4d4c901ba2572094c84274e9e7eb30534", size = 485590, upload-time = "2026-10-11T01:04:09.626Z" }, + { url = "https://files.pythonhosted.org/packages/06/eb/e9de75b8c6d2170c42c08ff303abf857ea8a6d9d9b6e99b5aba40f15e962/aiohttp-3.14.5-cp315-cp315-win_amd64.whl", hash = "sha256:a9d3983bd6ab7aa1cfd573544ae98df9b6cb6912a5185a198263e024a636861d", size = 511740, upload-time = "2026-10-11T01:04:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/fc/25/455f3c2785eb0d50748cffd0abd07500815f419a9495b14610b7622d8d2d/aiohttp-3.14.5-cp315-cp315-win_arm64.whl", hash = "sha256:e29347c142cf6e99e0dff5e2995ead1d50fa3b51bf37a7c726a7ccfe5419745a", size = 495171, upload-time = "2026-10-11T01:04:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6c/497f0a98782eebfcf0f02a7fbdef5428148bd426027140cbad494cf842b5/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9bf1d5dcc15204d9ec8b8ea4c18fd66e6b80e5de1f4ecbafb3a2f2740f8039d4", size = 855638, upload-time = "2026-10-11T01:04:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c5/55c0cef2572af9b1ee81608f7c0a1bf74e6c9151a73b04915d933f192335/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:14f04769cfefe4734016a856a83af36133cd17779cef9ae817f812b8ba9d6d51", size = 567455, upload-time = "2026-10-11T01:04:19.702Z" }, + { url = "https://files.pythonhosted.org/packages/4d/47/e1a0e39f4a2b881f6071225afaf94a6547001b5aefc1566734d73c685934/aiohttp-3.14.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6e4251c0ba4624a68a2c11471a1ac54c3306876c21f0ae86de085cc9241c8905", size = 567836, upload-time = "2026-10-11T01:04:22.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1d/817d85836f52b687160064a326e62e037dc42f1ad5b6b5188a70e5c134b5/aiohttp-3.14.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4f5cf4dc72a71c4cfa9751b4950be22f733626670230d46e7d606592aa22d59", size = 2051409, upload-time = "2026-10-11T01:04:24.821Z" }, + { url = "https://files.pythonhosted.org/packages/f7/25/e8ea6fc212a9aabce982917346ce8ecab0929c74b60232a056dd11c99da0/aiohttp-3.14.5-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dbf53ae2601b7fd5a93c3944deea3a78d40f495226d582c35ef7a433425ce2b2", size = 1936493, upload-time = "2026-10-11T01:04:27.456Z" }, + { url = "https://files.pythonhosted.org/packages/24/33/de0517f71f1a19feea4aff78a2ec4ec2d98634129ec30ead78fce2f8f81b/aiohttp-3.14.5-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:657291433bf4dd3142f3abac495764cd47d0c7c92087751e6666c6447e65fcef", size = 2021405, upload-time = "2026-10-11T01:04:30.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/11/ddaf2e7930543e9f0cad3a1c54e1af0c1bd2fae3973d568c4263d3b010e9/aiohttp-3.14.5-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e51a27980c3788e6e6b3325d694fdd4898087fa8a86b2763af77b39353da41e", size = 2142017, upload-time = "2026-10-11T01:04:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7b/58784353c06de8adc20f426daad3d85dd86fd55331c713a3f4b638c94573/aiohttp-3.14.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c7583cd3dfdc7dcc927835b4f6c7faae7ecc1ba3ca5879321621ae2e6f8e84", size = 2002482, upload-time = "2026-10-11T01:04:35.94Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/417d9535caa9e165ffe6a53e2347a78107e7c87dcb0e10aca315c3af0344/aiohttp-3.14.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fdcd6af7e2e51d1ba1b4bea16e97b074bcb7b5dd0246a9d8201341bb28085a0", size = 1791774, upload-time = "2026-10-11T01:04:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/01/25e49c2e8a01b9f0e19ca0a8448ad50aa2bdf96c8cd41e92bd45af044784/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c8859a013ae0de1074660992139a1a440df3e6b219b86cf0d3f11c2692bb4fe3", size = 1970358, upload-time = "2026-10-11T01:04:41.66Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fc/c132fd3465b6c7e4ce0193154f602c3e6c46b680e4da7eb3bd0d8a40d1c7/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:8966ecac808dd5f473c9c4cefd10cd3ffda71c18a4d3493b7c7d2ae1803bf2cc", size = 1969192, upload-time = "2026-10-11T01:04:44.66Z" }, + { url = "https://files.pythonhosted.org/packages/95/4e/d58b45e7dba4eb607eca11fc0a4aa77ae0f39c11afa804635a61ce18cff4/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3093b72c215bda16ce961a6d073f6e71d46e022962a9d5d457c5d4d421c78b57", size = 1963618, upload-time = "2026-10-11T01:04:47.505Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d9/f6ac50946efb3490428ef52b56e62c1c6b7f9c6ff3ec6083535526c83e60/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:149fb56caf7acb67073126f675d0958d9c4b3125fcd3f6d4877df98aa8a97ce9", size = 1784817, upload-time = "2026-10-11T01:04:50.287Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2f/f255eb63da788cd8a452fe350869c03266ccad7d884925f6963c87808f24/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:293d3ae7c6a0ed176a42e59a1b5fde825ead65c835360f734148e96729f928d2", size = 1995956, upload-time = "2026-10-11T01:04:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/241aa3393eaafda0034470add1625f82a4c252901108723b283a9b0b32ca/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3f2dcc00191fd563e9075181a14ec31d7dd63223ced7582cc70a15a499de0c79", size = 1967794, upload-time = "2026-10-11T01:04:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/a68e689288c9e4bfcf8d0979f2b12b774bf01861985420df6150eba5448e/aiohttp-3.14.5-cp315-cp315t-win32.whl", hash = "sha256:7779cd97e61ebe583ec2f1c5616cdd038aa08a4453b1848c67842176d054948e", size = 503876, upload-time = "2026-10-11T01:04:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/78/49b0299da6d54de19fc6fdc6889d50233ac47d192a461624f4fc010fde83/aiohttp-3.14.5-cp315-cp315t-win_amd64.whl", hash = "sha256:0e6f16f5e49c4b8267988c05ab07760d7064cea57d077c3d068d04b0fbb992cb", size = 530957, upload-time = "2026-10-11T01:05:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/b0afc936aeb6d2f93157e3408b069ec5d7934429ae7d023de5e0953b1887/aiohttp-3.14.5-cp315-cp315t-win_arm64.whl", hash = "sha256:1aead151c3abbac6b32942e452020cb66d7efc099d253cc6c20f748e926c858b", size = 510408, upload-time = "2026-10-11T01:05:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, ] [[package]] @@ -293,7 +335,8 @@ name = "anthropic" version = "0.109.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "distro" }, { name = "docstring-parser" }, { name = "httpx" }, @@ -309,15 +352,43 @@ wheels = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] dependencies = [ - { name = "idna" }, + { name = "idna", marker = "python_full_version < '3.15'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +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/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { 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 = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "idna", marker = "python_full_version >= '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -759,55 +830,65 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.0" +version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, - { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] [[package]] @@ -1101,7 +1182,8 @@ name = "google-genai" version = "2.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "distro" }, { name = "google-auth", extra = ["requests"] }, { name = "httpx" }, @@ -1226,15 +1308,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.4.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/9b/2b1d1833a58236d1f6ee755e027a3917da0db59cc9708554cefc440ee8b6/httpcore2-2.4.0.tar.gz", hash = "sha256:3093a8ab8980d9f910b9cb4351df9186a0ad2350a6284a9107ac9a362a584422", size = 64618, upload-time = "2026-06-11T06:35:53.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/72/4fdf2306143a92a471fad9f3655aa542d43aa9188a7c9534e82c9aecf837/httpcore2-2.4.0-py3-none-any.whl", hash = "sha256:5218779da5d6e3c2013ac706121abfb3815d450e0613495c0de50264dce58242", size = 80151, upload-time = "2026-06-11T06:35:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] [[package]] @@ -1242,7 +1324,8 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, @@ -1263,18 +1346,29 @@ wheels = [ [[package]] name = "httpx2" -version = "2.4.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15' and sys_platform != 'emscripten'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15' and sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/60/b43ced4ccf26e95b396dbf67051d3e5042b645917d4da0469dd82a3bdd4f/httpx2-2.4.0.tar.gz", hash = "sha256:32e0734b61eb0824b3f56a9e98d6d92d381a3ef12c0045aa917ee63df6c411ef", size = 81691, upload-time = "2026-06-11T06:35:54.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/45/82bc57c3d9c3314f663b67cc057f1c017a6450685dde513f4f8db5cf431f/httpx2-2.4.0-py3-none-any.whl", hash = "sha256:425acd99297829599decf6701386dd84db3542597d36d3e2e4def930ecd57fd9", size = 74941, upload-time = "2026-06-11T06:35:52.235Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -1720,7 +1814,7 @@ wheels = [ [[package]] name = "langgraph-sdk" -version = "0.4.2" +version = "0.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -1729,30 +1823,35 @@ dependencies = [ { name = "orjson" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/5d/cbeacb114f4a6269fc476f7d2feba088f6ada0c00f81bf5decc587f81511/langgraph_sdk-0.4.7.tar.gz", hash = "sha256:6827560be31e38daae1514234e9aa12c345dd40d4d4b94aa1b443729bfccda69", size = 350917, upload-time = "2026-10-12T22:54:05.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2b/996e641d2020f30b25a523e8cc43f068406eb2b5b677190b3ca917cbbd05/langgraph_sdk-0.4.7-py3-none-any.whl", hash = "sha256:a005c7ac662c318a3405e436e9effaa90c05343f9f4ae9e11dca19c9369727dd", size = 162399, upload-time = "2026-10-12T22:54:04.224Z" }, ] [[package]] name = "langsmith" -version = "0.8.16" +version = "0.14.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, + { name = "distro" }, + { name = "httpx2" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, { name = "packaging" }, { name = "pydantic" }, { name = "requests" }, { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, { name = "uuid-utils" }, { name = "websockets" }, { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/19/1ed2af9c6d5d7a148e6b3e809b0af8ce8848e1f66a0726c8223d30e5292b/langsmith-0.8.16.tar.gz", hash = "sha256:8c943f0c9185fe2a9637b5b442828b7efd823b1de28d50d14c136c79660f909b", size = 4513275, upload-time = "2026-06-15T17:41:24.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/78/53965f83edcaeb6a2e5eaf489951e1431752ba729eb6df24f4455b8470e9/langsmith-0.14.9.tar.gz", hash = "sha256:e54c41dcc06cbad971364fe5ecaaf976c11b418bdd0a125f816e2534c6caf2f2", size = 5008113, upload-time = "2026-10-15T13:34:43.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/13/8186a9867c67f3fef9958a1d60b45f46c1a9b5d28f67d8fd136f28ceab3f/langsmith-0.8.16-py3-none-any.whl", hash = "sha256:081e57c0175d142192683288740a796eb0eb32d9e703b4bf9133678ceefe3286", size = 500303, upload-time = "2026-06-15T17:41:22.33Z" }, + { url = "https://files.pythonhosted.org/packages/94/2d/eb8499785c05745e7b01822e3584bb8611b6684da811e776b3acab9ea024/langsmith-0.14.9-py3-none-any.whl", hash = "sha256:ae8a41f6b3712fe344c9163e16e06c6c1294623aaf5f565874da65f36482e7e4", size = 862514, upload-time = "2026-10-15T13:34:41.319Z" }, ] [[package]] @@ -2018,10 +2117,11 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.0" +version = "1.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "httpx" }, { name = "httpx-sse" }, { name = "jsonschema" }, @@ -2036,9 +2136,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, ] [[package]] @@ -2246,7 +2346,8 @@ name = "openai" version = "2.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "distro" }, { name = "httpx" }, { name = "jiter" }, @@ -2512,11 +2613,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -2632,16 +2733,17 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.1" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" }, ] [[package]] @@ -2655,11 +2757,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] @@ -2669,11 +2771,11 @@ crypto = [ [[package]] name = "pypdf" -version = "6.13.2" +version = "6.20.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/0a/48fe05c6bb3aa4bb4d2a4079a383d33c0dfec1edf613a642f07d8b8b5c2e/pypdf-6.13.2.tar.gz", hash = "sha256:5a96a17dbdfbf9c2ab24c0a13fa0aba182be22ba6f283098712c16fc242f509f", size = 6479250, upload-time = "2026-06-10T16:42:34.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/c1/da25a099164cf4b210d63b957c902ad687139f4b8c12c20aec7953a4a266/pypdf-6.20.1.tar.gz", hash = "sha256:28f5a9d2fdc2749264612d94e6a58de54c11d730d9f0cabf8ad34117c4942b45", size = 7075352, upload-time = "2026-10-12T16:14:24.784Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/17/378943705992f74e451a06de3401ce68e3213763c81e44d0614559c45599/pypdf-6.13.2-py3-none-any.whl", hash = "sha256:6eeb9e57693f29d41bd01255d02660cbbb41fd7fc818a982677389a35e4f2083", size = 346555, upload-time = "2026-06-10T16:42:32.37Z" }, + { url = "https://files.pythonhosted.org/packages/71/f8/4cbd09988b4b158260b7e0df38bf16f19e998bf0e257a18661a8da04280e/pypdf-6.20.1-py3-none-any.whl", hash = "sha256:aa5a55ddcffdc5e5ab291d5decb23f6383f4e56f8e3263dc39af41fff03885ad", size = 402665, upload-time = "2026-10-12T16:14:22.556Z" }, ] [[package]] @@ -2819,15 +2921,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.3.1" +version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/73/54993df8fc57e906dc9b7e01d1d9b0b2d3eb0ce3190639e33dccb12853f7/python_discovery-1.6.2.tar.gz", hash = "sha256:cd1738ca1d37c86ef9d0b654dd46fcee1e41c97c6add12575e8501ced39afdb4", size = 86921, upload-time = "2026-10-10T01:24:27.912Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/61/3b/50ed32583bfffa737df67d33b9c15d7b25df232c55ab606d059f610ef6ca/python_discovery-1.6.2-py3-none-any.whl", hash = "sha256:f0c697f95a3aaec4174a6e5e58f5885e25768684bafc76f1afde384709ebdcf2", size = 38912, upload-time = "2026-10-10T01:24:26.721Z" }, ] [[package]] @@ -3384,7 +3485,8 @@ name = "sse-starlette" version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "starlette" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } @@ -3394,15 +3496,16 @@ wheels = [ [[package]] name = "starlette" -version = "1.2.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" }, ] [[package]] @@ -3723,11 +3826,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -3810,17 +3913,18 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.3" +version = "21.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da", size = 5487482, upload-time = "2026-10-02T17:42:23.38Z" }, ] [[package]] @@ -4013,93 +4117,127 @@ wheels = [ [[package]] name = "yarl" -version = "1.24.2" +version = "1.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, - { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, - { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, - { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, - { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, - { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, - { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, - { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, - { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, - { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, - { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, - { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, - { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, - { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, - { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, - { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, - { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, - { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, - { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, - { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, - { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, - { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, - { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, - { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, - { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, - { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, - { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, ] [[package]] name = "yt-dlp" -version = "2026.3.17" +version = "2026.8.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e0/832fa4ca334b766a06933a196066edc3dba37cdb6f14cd98d59bcc69a4b4/yt_dlp-2026.8.19.tar.gz", hash = "sha256:9e213e48cea35c66b378e4447903f118f6392a5fa380a2b6d7070ec86f4e0af1", size = 3052025, upload-time = "2026-08-19T23:48:59.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" }, + { url = "https://files.pythonhosted.org/packages/69/b2/8cd1613f56eed7ceb64fbd4df3f1c01246bfb098e6f398228bafda22b80b/yt_dlp-2026.8.19-py3-none-any.whl", hash = "sha256:1d57897e94c6665a0a6f9bc54b34e584284e32c034ffab3a7df25d8f7b24eedf", size = 3185533, upload-time = "2026-08-19T23:48:56.925Z" }, ] [[package]]