From a796e6fa7bc0928111fdf8ee3d8cdce3ebb210de Mon Sep 17 00:00:00 2001 From: Jacob Lasky Date: Sat, 25 Jul 2026 13:04:03 -0400 Subject: [PATCH 1/4] fix(params): send keyterm, not keyterms, and stop dropping unnamed params Reported live: mic streaming died with AsyncV1Client.connect() got an unexpected keyword argument 'keyterms' Deepgram's parameter is the singular `keyterm`, repeated per term. The frontend models it as a plural list and its URL preview translated correctly, which is why the displayed URL looked right, but the request path forwarded the internal plural name verbatim. It failed in two different ways, and the quiet one is worse: - Streaming raised TypeError before any network call, killing the stream. - Batch SILENTLY IGNORED it. Deepgram drops unknown query params without erroring, so scripts/test_rrhaphy_keyterms.py ran its "WITH keyterms" arm with no keyterms applied at all. That comparison was no-keyterms against no-keyterms, and any conclusion drawn from those CSVs is unsupported. The run needs repeating. Fixed at the single parameter gate via PARAM_ALIASES in stt/options.py, so all five call sites are correct at once. `tags` had the same defect and is aliased to `tag`. A test asserting every emitted param is a real connect() keyword then found six more that would have crashed the stream identically: filler_words, no_delay, word_confidence, alternatives, diarize_version and entity_prompt. All are real Deepgram params the UI exposes; the SDK just does not enumerate them as keywords. So _params_to_sdk_kwargs now splits into two buckets: names the installed SDK enumerates go as kwargs, everything else is forwarded verbatim through RequestOptions.additional_query_parameters, which the SDK spreads into the WebSocket URL query (verified in deepgram/listen/v1/raw_client.py). The accepted set is computed from the installed SDK rather than hardcoded, so it widens on its own when a newer SDK names more params. Audited: 25 params go as kwargs, 6 via passthrough, 0 dropped. Also makes two tests hermetic. They asserted on the ambient APP_ACCESS_TOKEN, which breaks for any developer who has it in the repo's gitignored .env, since app.py calls load_dotenv() at import. --- app.py | 39 +++++++++++-- scripts/test_rrhaphy_keyterms.py | 8 ++- scripts/test_rrhaphy_multivoice.py | 3 + stt/options.py | 26 +++++++-- tests/test_options.py | 94 +++++++++++++++++++++++++++++- tests/test_security.py | 19 ++++-- 6 files changed, 173 insertions(+), 16 deletions(-) diff --git a/app.py b/app.py index db16442..823be59 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ import asyncio +import inspect import json as json_mod import logging import os @@ -759,12 +760,42 @@ async def transcribe(request: Request): # --- Helper functions --- +# Parameter names deepgram-sdk's connect() actually enumerates as keywords. +# Computed from the installed SDK, never hardcoded: the set grows between SDK +# releases, and a hardcoded copy silently rots into the bug below. +_SDK_CONNECT_KWARGS = frozenset( + inspect.signature(AsyncDeepgramClient(api_key="_").listen.v1.connect).parameters +) - {"self", "request_options"} + + def _params_to_sdk_kwargs(raw_params: dict) -> dict: - """Convert frontend params dict to deepgram-sdk 6.x keyword args. - model is required by connect() — default to nova-2 if not provided. + """Convert a frontend params dict into deepgram-sdk 6.x connect() arguments. + + Splits into two buckets, because the SDK enumerates only ~28 of Deepgram's + query parameters as keywords and raises TypeError on anything else: + + AsyncV1Client.connect() got an unexpected keyword argument 'keyterms' + + That killed the stream for `keyterms`, and equally for `filler_words`, + `no_delay`, `word_confidence`, `alternatives`, `diarize_version` and + `entity_prompt`, all of which are real Deepgram params the UI exposes. + Anything the SDK does not name is forwarded verbatim through + RequestOptions.additional_query_parameters, so it still reaches the wire. + + DO NOT "fix" a future occurrence by deleting the param from the UI. Add it + to nothing: the split handles unknown names automatically, and widens on its + own when a newer SDK starts naming them. + + model is required by connect(), so default it. """ - kwargs = serialize_params(raw_params, Mode.STREAMING) - kwargs.setdefault("model", "nova-2") + serialized = serialize_params(raw_params, Mode.STREAMING) + serialized.setdefault("model", "nova-2") + + kwargs = {k: v for k, v in serialized.items() if k in _SDK_CONNECT_KWARGS} + passthrough = {k: v for k, v in serialized.items() if k not in _SDK_CONNECT_KWARGS} + if passthrough: + logger.debug("params not named by the SDK, sent as query: %s", sorted(passthrough)) + kwargs["request_options"] = {"additional_query_parameters": passthrough} return kwargs diff --git a/scripts/test_rrhaphy_keyterms.py b/scripts/test_rrhaphy_keyterms.py index 2edc067..6c002e6 100644 --- a/scripts/test_rrhaphy_keyterms.py +++ b/scripts/test_rrhaphy_keyterms.py @@ -6,6 +6,9 @@ """ import os import httpx +from dotenv import load_dotenv + +load_dotenv() import json import csv import sys @@ -60,7 +63,10 @@ def run_test(use_keyterms: bool) -> list[dict]: stt_params = {"model": STT_MODEL, "smart_format": True} if use_keyterms: # keyterms with intensifier boost - stt_params["keyterms"] = list(TERMS) + # Canonical Deepgram wire name. stt.options.PARAM_ALIASES also maps the + # UI's plural "keyterms", but be explicit here: sending the wrong name + # to the batch API is silently ignored, which invalidated an earlier run. + stt_params["keyterm"] = list(TERMS) label = "WITH keyterms" if use_keyterms else "WITHOUT keyterms" print(f"\n{'='*60}") diff --git a/scripts/test_rrhaphy_multivoice.py b/scripts/test_rrhaphy_multivoice.py index 3f71e71..fb3c98a 100644 --- a/scripts/test_rrhaphy_multivoice.py +++ b/scripts/test_rrhaphy_multivoice.py @@ -6,6 +6,9 @@ """ import os import httpx +from dotenv import load_dotenv + +load_dotenv() import csv import sys from datetime import datetime diff --git a/stt/options.py b/stt/options.py index f042b76..fb15cf4 100644 --- a/stt/options.py +++ b/stt/options.py @@ -17,6 +17,23 @@ class Mode(str, Enum): # Params that should never be sent to Deepgram (handled by client) INTERNAL_PARAMS = {"base_url"} +# UI-internal name -> Deepgram wire name. +# +# The frontend models these as plural lists (`keyterms`, `tags`) but Deepgram's +# parameters are singular and repeated once per value (`keyterm`, `tag`). +# Translating here, at the single parameter gate, makes every call site correct +# at once. +# +# This bug was live and it failed in two different ways, which is why the alias +# belongs in code and not in a comment telling people to remember: +# - Streaming raised `AsyncV1Client.connect() got an unexpected keyword +# argument 'keyterms'`, because the SDK only accepts `keyterm`. +# - Batch SILENTLY IGNORED it. Deepgram drops unknown query params without +# erroring, so a "with keyterms" experiment ran with no boost applied and +# looked like evidence that keyterms do not help. +# DO NOT rename the UI field to work around this; the alias is the fix. +PARAM_ALIASES = {"keyterms": "keyterm", "tags": "tag"} + # Params a caller must never be able to set, on any endpoint. # `callback` makes Deepgram POST the finished transcript to a URL of the # caller's choosing, so on a public unauthenticated app it is a data-exfil @@ -29,7 +46,7 @@ class Mode(str, Enum): def clean_params(params: dict, mode: Mode) -> dict: """ Remove internal params, mode-incompatible params, empty/falsy values, - and handle special cases (keyterms list, redact list, etc.) + and map UI-internal names to Deepgram wire names (see PARAM_ALIASES). Returns clean dict ready to send to Deepgram as query params. """ result = {} @@ -45,10 +62,7 @@ def clean_params(params: dict, mode: Mode) -> dict: continue if isinstance(value, bool) and not value: continue - result[key] = value - - # Handle keyterms: list of "term" or "term:weight" strings -> repeated keyterm= params - # (handled by requests library when value is a list) + result[PARAM_ALIASES.get(key, key)] = value # Handle extra params: merge into result. # Re-apply the deny list here. This merge runs AFTER the filter loop above, @@ -57,7 +71,7 @@ def clean_params(params: dict, mode: Mode) -> dict: if "extra" in result and isinstance(result["extra"], dict): extra = result.pop("extra") result.update({ - k: v for k, v in extra.items() + PARAM_ALIASES.get(k, k): v for k, v in extra.items() if k not in DENIED_PARAMS and k not in INTERNAL_PARAMS }) diff --git a/tests/test_options.py b/tests/test_options.py index 9947651..073989c 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -63,4 +63,96 @@ def test_list_values_kept(): params = {"redact": ["pci", "ssn"], "keyterms": ["hello:2", "world"]} result = clean_params(params, Mode.STREAMING) assert result["redact"] == ["pci", "ssn"] - assert result["keyterms"] == ["hello:2", "world"] + # keyterms is aliased to Deepgram's singular wire name; see PARAM_ALIASES. + assert result["keyterm"] == ["hello:2", "world"] + + +# --------------------------------------------------------------------------- +# keyterms -> keyterm. This was a live bug that failed two different ways: +# streaming raised "unexpected keyword argument 'keyterms'", and batch silently +# ignored it so a keyterm experiment ran with no boost applied. +# --------------------------------------------------------------------------- + +def test_keyterms_is_aliased_to_the_wire_name(): + from stt.options import clean_params, Mode + out = clean_params({"model": "nova-3", "keyterms": ["alpha", "beta"]}, Mode.STREAMING) + assert "keyterms" not in out, "the UI-internal plural must never reach Deepgram" + assert out["keyterm"] == ["alpha", "beta"] + + +def test_keyterms_alias_applies_in_batch_mode_too(): + """Batch is where the wrong name was silently dropped rather than erroring.""" + from stt.options import clean_params, Mode + out = clean_params({"keyterms": ["alpha"]}, Mode.BATCH) + assert out == {"keyterm": ["alpha"]} + + +def test_keyterms_alias_applies_through_extra(): + from stt.options import clean_params, Mode + out = clean_params({"extra": {"keyterms": ["alpha"]}}, Mode.BATCH) + assert out == {"keyterm": ["alpha"]} + + +def test_keyterm_passed_directly_is_untouched(): + from stt.options import clean_params, Mode + out = clean_params({"keyterm": ["alpha"]}, Mode.STREAMING) + assert out == {"keyterm": ["alpha"]} + + +def test_query_string_repeats_keyterm_per_item(): + from stt.options import query_string, Mode + qs = query_string({"keyterms": ["one", "two words"]}, Mode.STREAMING) + assert qs.count("keyterm=") == 2 + assert "keyterms=" not in qs + + +def test_sdk_accepts_every_serialized_param_name(): + """Guards the whole class of bug: a param name the SDK rejects raises + TypeError at connect() and kills the stream. Assert every name we emit for + streaming is a real connect() keyword.""" + import inspect + from deepgram import AsyncDeepgramClient + from stt.options import serialize_params, Mode + + accepted = set(inspect.signature( + AsyncDeepgramClient(api_key="x").listen.v1.connect + ).parameters) + + import json + from pathlib import Path + defaults = json.loads( + (Path(__file__).resolve().parents[1] / "config" / "defaults.json").read_text() + ) + # Give every default a truthy value so none are dropped as falsy. + probe = {} + for k, v in defaults.items(): + if isinstance(v, bool): + probe[k] = True + elif isinstance(v, list): + probe[k] = ["x"] + elif isinstance(v, dict): + continue # `extra` is a passthrough bag, not a param itself + elif isinstance(v, (int, float)): + probe[k] = v or 1 + else: + probe[k] = "x" + + emitted = set(serialize_params(probe, Mode.STREAMING)) + + # Every emitted param must reach the wire by one of the two routes, and the + # ones the SDK does not name must go through the passthrough rather than + # being dropped or raising TypeError at connect(). + import app + built = app._params_to_sdk_kwargs(probe) + via_kwargs = set(built) - {"request_options"} + via_query = set( + built.get("request_options", {}).get("additional_query_parameters", {}) + ) + + assert via_kwargs <= accepted, f"would raise TypeError at connect(): {sorted(via_kwargs - accepted)}" + dropped = emitted - via_kwargs - via_query + assert not dropped, f"params silently dropped, never reaching Deepgram: {sorted(dropped)}" + # These are real Deepgram params the SDK does not enumerate; they must be + # routed, not lost. Regression guard for the keyterms crash class. + for name in ("filler_words", "no_delay", "word_confidence"): + assert name in via_query, f"{name} is not being forwarded" diff --git a/tests/test_security.py b/tests/test_security.py index 36f2843..ffd2569 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -153,8 +153,13 @@ def test_require_auth_without_a_token_is_a_hard_error(): "REQUIRE_AUTH": "true", "DEEPGRAM_API_KEY": "test-key", "PYTHONPATH": str(Path(__file__).resolve().parents[1]), + # Set EMPTY rather than popping it. app.py calls load_dotenv(), which + # would repopulate a missing key from the repo's gitignored .env and + # make this assertion depend on the developer's local file. dotenv does + # not override a key already present in the environment, even when its + # value is the empty string, so this is hermetic either way. + "APP_ACCESS_TOKEN": "", } - env.pop("APP_ACCESS_TOKEN", None) proc = subprocess.run( [sys.executable, "-c", "import app"], capture_output=True, text=True, env=env, @@ -206,10 +211,16 @@ def _reset_rate_limits(): app._anon_global_hits.clear() -def test_everyone_is_privileged_when_no_token_is_configured(): - """Local dev and this suite run with the limits off.""" - assert app.APP_ACCESS_TOKEN == "" +def test_everyone_is_privileged_when_no_token_is_configured(monkeypatch): + """With no token configured, the limits are off. + + DO NOT assert on the ambient app.APP_ACCESS_TOKEN here. app.py calls + load_dotenv() at import, so a developer with APP_ACCESS_TOKEN in the repo's + gitignored .env would fail this test for no real reason. Pin the value. + """ + monkeypatch.setattr(app, "APP_ACCESS_TOKEN", "") assert app._is_privileged("") is True + assert app._is_privileged("anything") is True req = _FakeRequest() assert app._enforce_access(req) is None assert req.state.privileged is True From 39d74bcb0e69eb1f8ff232a8041d9aa00f89530b Mon Sep 17 00:00:00 2001 From: Jacob Lasky Date: Sat, 25 Jul 2026 13:04:03 -0400 Subject: [PATCH 2/4] fix(params): keyterm is prompting, not boosting, and drop nova-2 keywords Terminology correction with teeth: Keyterm Prompting (`keyterm`, Nova-3 and Flux) improves Keyword Recall Rate and takes BARE TERMS. It has no intensifier syntax. Keyword boosting is the separate, legacy `keywords` parameter (Nova-2 and older), weighted as `keywords=TERM:INTENSIFIER`. Verified against deepgram-docs fern/pages/speech-to-text/custom-vocabulary/{keyterm,keywords}. The two had been conflated in this repo, and the weight syntax leaked onto keyterms: a test fixture used `keyterms: ["hello:2", "world"]` and the harness called it "intensifier boost". As a keyterm, "hello:2" prompts for the literal string "hello:2", so anyone copying that pattern silently gets a useless term. Drops `keywords` entirely per Jake: this app targets Nova-3 and Flux, and a Nova-2-only parameter sitting in the params panel invites sending it on a Nova-3 request, where Deepgram ignores it silently and the user concludes the feature is broken. Removed from config/defaults.json, the Alpine state, and the template, and added to DENIED_PARAMS so it cannot arrive via `extra` either. --- config/defaults.json | 1 - scripts/test_rrhaphy_keyterms.py | 2 +- static/app.js | 2 -- stt/options.py | 21 +++++++++++++++--- templates/index.html | 4 ---- tests/test_options.py | 37 +++++++++++++++++++++++++++++--- 6 files changed, 53 insertions(+), 14 deletions(-) diff --git a/config/defaults.json b/config/defaults.json index 276d8a5..8a411ba 100644 --- a/config/defaults.json +++ b/config/defaults.json @@ -23,7 +23,6 @@ "redact": [], "keyterms": [], "entity_prompt": "", - "keywords": "", "search": "", "replace": "", "topics": false, diff --git a/scripts/test_rrhaphy_keyterms.py b/scripts/test_rrhaphy_keyterms.py index 6c002e6..c0f3eec 100644 --- a/scripts/test_rrhaphy_keyterms.py +++ b/scripts/test_rrhaphy_keyterms.py @@ -62,7 +62,7 @@ def run_test(use_keyterms: bool) -> list[dict]: rows = [] stt_params = {"model": STT_MODEL, "smart_format": True} if use_keyterms: - # keyterms with intensifier boost + # Keyterm Prompting: bare terms, no intensifiers (that is `keywords`). # Canonical Deepgram wire name. stt.options.PARAM_ALIASES also maps the # UI's plural "keyterms", but be explicit here: sending the wrong name # to the batch API is silently ignored, which invalidated an earlier run. diff --git a/static/app.js b/static/app.js index 8f059c3..6044708 100644 --- a/static/app.js +++ b/static/app.js @@ -313,7 +313,6 @@ function appData() { redact: [], keyterms: [], entity_prompt: '', - keywords: '', search: '', replace: '', topics: false, @@ -1098,7 +1097,6 @@ function appData() { redact: [], keyterms: [], entity_prompt: '', - keywords: '', search: '', replace: '', topics: false, diff --git a/stt/options.py b/stt/options.py index fb15cf4..66092e1 100644 --- a/stt/options.py +++ b/stt/options.py @@ -24,13 +24,20 @@ class Mode(str, Enum): # Translating here, at the single parameter gate, makes every call site correct # at once. # +# `keyterm` is KEYTERM PROMPTING (Nova-3 and Flux). It improves Keyword Recall +# Rate and takes BARE TERMS ONLY. It is NOT keyword boosting and has NO +# intensifier syntax: a value of "term:2" prompts for the literal string +# "term:2". Weighted boosting is the separate, legacy `keywords` parameter +# (Nova-2 and older), which this app deliberately does not support. +# Join words with %20 or + to prompt a multi-word phrase as one cohesive unit. +# # This bug was live and it failed in two different ways, which is why the alias # belongs in code and not in a comment telling people to remember: # - Streaming raised `AsyncV1Client.connect() got an unexpected keyword # argument 'keyterms'`, because the SDK only accepts `keyterm`. # - Batch SILENTLY IGNORED it. Deepgram drops unknown query params without -# erroring, so a "with keyterms" experiment ran with no boost applied and -# looked like evidence that keyterms do not help. +# erroring, so a "with keyterms" experiment ran with no keyterms applied at +# all and looked like evidence that keyterm prompting does not help. # DO NOT rename the UI field to work around this; the alias is the fix. PARAM_ALIASES = {"keyterms": "keyterm", "tags": "tag"} @@ -40,7 +47,15 @@ class Mode(str, Enum): # primitive AND a way to run arbitrary async jobs on the server's account. # DO NOT move this into INTERNAL_PARAMS: those are stripped because the client # consumes them, these are stripped because forwarding them is a vulnerability. -DENIED_PARAMS = {"callback"} +DENIED_PARAMS = { + "callback", + # Legacy Nova-2-and-older keyword BOOSTING (`keywords=TERM:INTENSIFIER`). + # Deliberately unsupported: this app targets Nova-3 and Flux, where the + # equivalent is Keyterm Prompting via `keyterm`. Keeping a Nova-2-only + # parameter in the UI invites sending it on a Nova-3 request, where Deepgram + # silently ignores it and the user concludes the feature does not work. + "keywords", +} def clean_params(params: dict, mode: Mode) -> dict: diff --git a/templates/index.html b/templates/index.html index 2649959..f5c7b80 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1361,10 +1361,6 @@
Replace
-
-
Keywords
- -
diff --git a/tests/test_options.py b/tests/test_options.py index 073989c..d3d3241 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -60,17 +60,20 @@ def test_extra_dict_merged(): def test_list_values_kept(): - params = {"redact": ["pci", "ssn"], "keyterms": ["hello:2", "world"]} + # Bare terms only. "hello:2" would be a keyWORDS intensifier, which is a + # different (Nova-2-only) parameter; as a keyterm it prompts for the literal + # string "hello:2". + params = {"redact": ["pci", "ssn"], "keyterms": ["hello", "world"]} result = clean_params(params, Mode.STREAMING) assert result["redact"] == ["pci", "ssn"] # keyterms is aliased to Deepgram's singular wire name; see PARAM_ALIASES. - assert result["keyterm"] == ["hello:2", "world"] + assert result["keyterm"] == ["hello", "world"] # --------------------------------------------------------------------------- # keyterms -> keyterm. This was a live bug that failed two different ways: # streaming raised "unexpected keyword argument 'keyterms'", and batch silently -# ignored it so a keyterm experiment ran with no boost applied. +# ignored it so a keyterm experiment ran with no keyterms applied at all. # --------------------------------------------------------------------------- def test_keyterms_is_aliased_to_the_wire_name(): @@ -156,3 +159,31 @@ def test_sdk_accepts_every_serialized_param_name(): # routed, not lost. Regression guard for the keyterms crash class. for name in ("filler_words", "no_delay", "word_confidence"): assert name in via_query, f"{name} is not being forwarded" + + +def test_nova2_keywords_is_not_supported(): + """Deliberately dropped. `keywords` is Nova-2-and-older keyword BOOSTING + (`keywords=TERM:INTENSIFIER`); this app targets Nova-3 and Flux, where the + equivalent is Keyterm Prompting via `keyterm`. Leaving a Nova-2-only param + in the UI invites sending it on a Nova-3 request, where Deepgram silently + ignores it and the user concludes the feature is broken.""" + from stt.options import clean_params, Mode + for mode in (Mode.BATCH, Mode.STREAMING): + assert "keywords" not in clean_params({"keywords": "term:2"}, mode) + assert "keywords" not in clean_params({"extra": {"keywords": "term:2"}}, mode) + + +def test_keyterm_takes_bare_terms_not_intensifiers(): + """Keyterm Prompting has NO intensifier syntax. Documented so nobody + reintroduces `term:2` thinking it weights the term; it would prompt for the + literal string. Weights belong to `keywords`, which is unsupported here.""" + from stt.options import query_string, Mode + qs = query_string({"keyterms": ["perineorrhaphy"]}, Mode.STREAMING) + assert qs == "keyterm=perineorrhaphy" + + +def test_keywords_gone_from_ui_defaults(): + import json + from pathlib import Path + d = json.loads((Path(__file__).resolve().parents[1] / "config" / "defaults.json").read_text()) + assert "keywords" not in d From f01cab8fa2581bc846b9d3e7c5360e89d72d522f Mon Sep 17 00:00:00 2001 From: Jacob Lasky Date: Sat, 25 Jul 2026 13:04:03 -0400 Subject: [PATCH 3/4] fix(params): union repeatable params, enforce deny list on the wire name Review of the keyterm fix found two more ways a param can go missing or slip through, plus the fact that nothing in the suite had ever looked at a URL. 1. Alias collisions silently dropped a value. Two sources can reach one wire param at once: the UI's plural `keyterms` and an explicit extra={"keyterm": ...}. A plain assignment kept whichever came last in dict order. Repeatable params (keyterm, tag, redact, search, replace) are now unioned, order-preserving and deduped, which is what Deepgram's repeat-the- key-per-value encoding means. Single-valued params still take one value, because unioning `model` would put a list where a string belongs. 2. The deny list was enforced on the caller's param name, not the wire name it maps to. No live bypass existed, since no alias currently targets a denied param, but the invariant was unguarded: adding one would have smuggled `callback` straight through. _is_blocked() now checks both names, and a test monkeypatches an alias onto `callback` to guard the invariant rather than today's alias table. Adds tests/test_wire_contract.py, which stands up a real WebSocket server on loopback, points the SDK's environment at it, and asserts on the request path the SDK actually sent. Every other test here checks the dict we hand the SDK, which is only the producer half of the contract: the SDK could accept a kwarg and drop it, and nothing would notice. That is precisely how the keyterms bug survived, present in our dict and absent on the wire. No credential needed. Verified these tests fail on the pre-fix code, with the reported symptom: TypeError: AsyncV1Client.connect() got an unexpected keyword argument 'keyterms' TypeError: AsyncV1Client.connect() got an unexpected keyword argument 'filler_words' The helper deliberately re-raises TypeError instead of swallowing it, so the failure names the user's error rather than "server never saw a handshake". Also moves load_dotenv() below the imports in both -rrhaphy harnesses, where it had been wedged mid-import-block, and says what it is for. 104 passed, 1 skipped. --- scripts/test_rrhaphy_keyterms.py | 13 ++- scripts/test_rrhaphy_multivoice.py | 13 ++- stt/options.py | 70 ++++++++++--- tests/test_options.py | 46 +++++++++ tests/test_wire_contract.py | 159 +++++++++++++++++++++++++++++ 5 files changed, 277 insertions(+), 24 deletions(-) create mode 100644 tests/test_wire_contract.py diff --git a/scripts/test_rrhaphy_keyterms.py b/scripts/test_rrhaphy_keyterms.py index c0f3eec..ced8e6d 100644 --- a/scripts/test_rrhaphy_keyterms.py +++ b/scripts/test_rrhaphy_keyterms.py @@ -4,16 +4,19 @@ Runs triplicates without keyterms, then triplicates with keyterms. Uses the /api/tts-transcribe endpoint on deepgram-python-stt.fly.dev. """ +import csv +import json import os +import sys +from datetime import datetime +from pathlib import Path + import httpx from dotenv import load_dotenv +# Reads APP_ACCESS_TOKEN from the repo's gitignored .env so the harness gets the +# privileged (un-rate-limited) tier. Must run before the getenv calls below. load_dotenv() -import json -import csv -import sys -from datetime import datetime -from pathlib import Path # Output lands next to this script. DO NOT hardcode an absolute /coding path: # it pins the harness to one machine. diff --git a/scripts/test_rrhaphy_multivoice.py b/scripts/test_rrhaphy_multivoice.py index fb3c98a..1b73276 100644 --- a/scripts/test_rrhaphy_multivoice.py +++ b/scripts/test_rrhaphy_multivoice.py @@ -4,16 +4,19 @@ 10 Deepgram TTS voices × 8 terms × 3 trials × 2 conditions (no keyterms / with keyterms). Uses /api/tts-transcribe on deepgram-python-stt.fly.dev. """ +import csv import os +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path + import httpx from dotenv import load_dotenv +# Reads APP_ACCESS_TOKEN from the repo's gitignored .env so the harness gets the +# privileged (un-rate-limited) tier. Must run before the getenv calls below. load_dotenv() -import csv -import sys -from datetime import datetime -from pathlib import Path -from concurrent.futures import ThreadPoolExecutor, as_completed # Output lands next to this script. DO NOT hardcode an absolute /coding path: # it pins the harness to one machine. diff --git a/stt/options.py b/stt/options.py index 66092e1..ad584eb 100644 --- a/stt/options.py +++ b/stt/options.py @@ -41,6 +41,16 @@ class Mode(str, Enum): # DO NOT rename the UI field to work around this; the alias is the fix. PARAM_ALIASES = {"keyterms": "keyterm", "tags": "tag"} +# Wire params Deepgram accepts more than once per request (the key is repeated, +# once per value). Two sources can land on the SAME wire name: the UI's plural +# alias (`keyterms`) and a caller-supplied `extra={"keyterm": ...}`. A plain +# assignment drops one of them, and WHICH one depends on dict iteration order, +# so these are unioned instead. Deepgram repeats the key per value, so a union +# is exactly what the caller asked for. DO NOT collapse this back to an +# overwrite: silently dropping half a keyterm list is the same class of bug as +# the plural/singular mismatch above, and just as invisible in a transcript. +REPEATABLE_PARAMS = {"keyterm", "tag", "redact", "search", "replace"} + # Params a caller must never be able to set, on any endpoint. # `callback` makes Deepgram POST the finished transcript to a URL of the # caller's choosing, so on a public unauthenticated app it is a data-exfil @@ -58,37 +68,69 @@ class Mode(str, Enum): } +def _is_blocked(caller_name: str, wire_name: str) -> bool: + """True if a param must not reach Deepgram. + + Tests BOTH the name the caller used and the wire name it maps to. Checking + only the caller's name would let any future alias whose target is denied + smuggle that target past the gate, because the deny list is enforced on the + name that actually goes on the wire. + """ + return bool({caller_name, wire_name} & (INTERNAL_PARAMS | DENIED_PARAMS)) + + +def _put(result: dict, wire_name: str, value) -> None: + """Write one already-aliased param, unioning repeatable ones (see REPEATABLE_PARAMS).""" + if wire_name in result and wire_name in REPEATABLE_PARAMS: + existing = result[wire_name] if isinstance(result[wire_name], list) else [result[wire_name]] + incoming = value if isinstance(value, list) else [value] + # dict.fromkeys dedupes while preserving order; a repeated keyterm is + # noise on the wire, and order is what the user typed. + result[wire_name] = list(dict.fromkeys([*existing, *incoming])) + return + result[wire_name] = value + + def clean_params(params: dict, mode: Mode) -> dict: """ Remove internal params, mode-incompatible params, empty/falsy values, and map UI-internal names to Deepgram wire names (see PARAM_ALIASES). Returns clean dict ready to send to Deepgram as query params. """ - result = {} + result: dict = {} for key, value in params.items(): - if key in INTERNAL_PARAMS or key in DENIED_PARAMS: + wire = PARAM_ALIASES.get(key, key) + if _is_blocked(key, wire): continue - if mode == Mode.STREAMING and key in BATCH_ONLY: + if mode == Mode.STREAMING and wire in BATCH_ONLY: continue - if mode == Mode.BATCH and key in STREAMING_ONLY: + if mode == Mode.BATCH and wire in STREAMING_ONLY: continue # Skip falsy values (but not 0 for numeric params, not False for booleans that are explicitly set) if value is None or value == "" or value == [] or value == {}: continue if isinstance(value, bool) and not value: continue - result[PARAM_ALIASES.get(key, key)] = value - - # Handle extra params: merge into result. - # Re-apply the deny list here. This merge runs AFTER the filter loop above, - # so without it a caller smuggles a blocked param straight through as - # extra={"callback": "..."} and the deny list above does nothing. + _put(result, wire, value) + + # Handle extra params: merge into result. `extra` is the deliberate escape + # hatch for params this app does not model, so it skips the mode and falsy + # filters above — but NOT the deny list. + # + # Re-applying the deny list here is load-bearing. This merge runs AFTER the + # filter loop, so without it a caller smuggles a blocked param straight + # through as extra={"callback": "..."} and the deny list above does nothing. + # + # NOTE: Deepgram also has its own unrelated `extra` query param (arbitrary + # metadata echoed back in the response). A non-dict `extra` is left alone + # above and forwarded as that param; only a dict means "merge these". if "extra" in result and isinstance(result["extra"], dict): extra = result.pop("extra") - result.update({ - PARAM_ALIASES.get(k, k): v for k, v in extra.items() - if k not in DENIED_PARAMS and k not in INTERNAL_PARAMS - }) + for k, v in extra.items(): + wire = PARAM_ALIASES.get(k, k) + if _is_blocked(k, wire): + continue + _put(result, wire, v) return result diff --git a/tests/test_options.py b/tests/test_options.py index d3d3241..ca80b48 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -187,3 +187,49 @@ def test_keywords_gone_from_ui_defaults(): from pathlib import Path d = json.loads((Path(__file__).resolve().parents[1] / "config" / "defaults.json").read_text()) assert "keywords" not in d + + +def test_alias_and_wire_name_from_two_sources_are_unioned_not_dropped(): + """A caller can reach one wire param by two names at once: the UI's plural + `keyterms` and an explicit `extra={"keyterm": ...}`. A plain assignment + dropped one of them depending on dict order, which is invisible in a + transcript. Repeatable params union instead.""" + from stt.options import clean_params, Mode + out = clean_params( + {"keyterms": ["alpha"], "extra": {"keyterm": ["beta"]}}, Mode.STREAMING + ) + assert out["keyterm"] == ["alpha", "beta"] + + # Order preserved, duplicates collapsed — a repeated keyterm is wire noise. + out = clean_params( + {"keyterms": ["alpha", "beta"], "extra": {"keyterm": "alpha"}}, Mode.STREAMING + ) + assert out["keyterm"] == ["alpha", "beta"] + + +def test_non_repeatable_collision_still_takes_one_value(): + """Only params Deepgram accepts more than once are unioned. `model` is + single-valued: unioning it would put a list where a string belongs.""" + from stt.options import clean_params, Mode + out = clean_params({"model": "nova-3", "extra": {"model": "nova-2"}}, Mode.STREAMING) + assert out["model"] == "nova-2" # extra is the escape hatch and wins + + +def test_alias_cannot_smuggle_a_denied_wire_name(): + """The deny list is enforced on the name that actually goes on the wire, not + just the name the caller typed, so a future alias pointing at a denied param + cannot bypass it. Guards the invariant, not today's alias table.""" + from stt.options import clean_params, Mode, PARAM_ALIASES, DENIED_PARAMS + import stt.options as options + + original = dict(PARAM_ALIASES) + try: + options.PARAM_ALIASES = {**original, "harmless": "callback"} + out = clean_params({"harmless": "https://evil.example/x"}, Mode.BATCH) + assert out == {}, f"denied wire name reached the request: {out}" + out = clean_params({"extra": {"harmless": "https://evil.example/x"}}, Mode.BATCH) + assert out == {}, f"denied wire name reached the request via extra: {out}" + finally: + options.PARAM_ALIASES = original + + assert "callback" in DENIED_PARAMS diff --git a/tests/test_wire_contract.py b/tests/test_wire_contract.py new file mode 100644 index 0000000..bbaaf64 --- /dev/null +++ b/tests/test_wire_contract.py @@ -0,0 +1,159 @@ +"""What actually arrives at Deepgram's WebSocket, asserted against a real handshake. + +Every other test in this suite checks the dict we hand to the SDK. That is the +producer half of the contract. This file checks the consumer half: it stands up a +real WebSocket server on loopback, points the SDK's environment at it, and reads +the request path the SDK actually sent. + +Why this is not redundant with test_options.py: `_params_to_sdk_kwargs` splits +params into named kwargs and `request_options.additional_query_parameters`, and +nothing in a dict-shaped assertion proves the SDK does anything with the second +bucket. It could accept the kwarg and drop it. The keyterms bug was exactly this +class of failure — the value was present in our dict and absent on the wire — and +it survived because no test ever looked at a URL. + +No Deepgram credential is needed, and none should ever be added here: the server +is ours, the handshake is local, and the assertion is on the path string. +""" +import pytest +from deepgram import AsyncDeepgramClient +from deepgram.environment import DeepgramClientEnvironment +from websockets.asyncio.server import serve + +from app import _params_to_sdk_kwargs + + +async def _handshake_path(params: dict) -> str: + """Connect the SDK to a local WS server; return the path it requested.""" + seen: list[str] = [] + + async def handler(ws): + seen.append(ws.request.path) + await ws.close() + + async with await serve(handler, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + url = f"ws://127.0.0.1:{port}" + dg = AsyncDeepgramClient( + api_key="wire-contract-test-key", + environment=DeepgramClientEnvironment( + base=f"http://127.0.0.1:{port}", production=url, agent=url + ), + ) + try: + async with dg.listen.v1.connect(**_params_to_sdk_kwargs(params)): + pass + except TypeError: + # DO NOT swallow this. A TypeError from connect() IS the reported + # bug ("got an unexpected keyword argument 'keyterms'"): it fires + # before the handshake, so the assertion below would otherwise + # report a confusing "server never saw a handshake" and hide the + # symptom the user actually saw. + raise + except Exception: + # Anything else is the server closing on us immediately, which is + # what it is written to do. By this point the handshake, and + # therefore the path, has already happened. + pass + + assert seen, "server never saw a handshake" + return seen[0] + + +def _pairs(path: str) -> list[tuple[str, str]]: + from urllib.parse import parse_qsl, urlsplit + + return parse_qsl(urlsplit(path).query, keep_blank_values=True) + + +@pytest.mark.asyncio +async def test_sdk_named_params_reach_the_wire(): + pairs = _pairs(await _handshake_path({"model": "nova-3", "smart_format": True})) + assert ("model", "nova-3") in pairs + # Booleans must be the strings Deepgram accepts, never Python's "True". + assert ("smart_format", "true") in pairs + + +@pytest.mark.asyncio +async def test_passthrough_params_reach_the_wire(): + """The six real Deepgram params the SDK does not enumerate as keywords. + + Before the kwarg split these raised TypeError at connect() and killed the + stream. Asserting they are ON THE URL is the only proof that routing them + through additional_query_parameters actually works. + """ + params = { + "model": "nova-3", + "filler_words": True, + "no_delay": True, + "word_confidence": True, + "alternatives": 3, + "diarize_version": "v2", + "entity_prompt": "patient names", + } + pairs = _pairs(await _handshake_path(params)) + for expected in ( + ("filler_words", "true"), + ("no_delay", "true"), + ("word_confidence", "true"), + ("alternatives", "3"), + ("diarize_version", "v2"), + ("entity_prompt", "patient names"), + ): + assert expected in pairs, f"{expected[0]} never reached the wire: {pairs}" + + +@pytest.mark.asyncio +async def test_keyterm_is_singular_and_repeated_per_term(): + """The reported bug, asserted at the wire. + + `keyterms` (plural) is a UI-internal name. Deepgram's param is `keyterm`, + repeated once per term. A plural key on this URL means the alias regressed; + a single comma-joined value means list encoding regressed. + """ + pairs = _pairs( + await _handshake_path( + {"model": "nova-3", "keyterms": ["perineorrhaphy", "herniorrhaphy"]} + ) + ) + assert ("keyterm", "perineorrhaphy") in pairs + assert ("keyterm", "herniorrhaphy") in pairs + assert not any(k == "keyterms" for k, _ in pairs), f"plural leaked: {pairs}" + assert [v for k, v in pairs if k == "keyterm"] == [ + "perineorrhaphy", + "herniorrhaphy", + ] + + +@pytest.mark.asyncio +async def test_multiword_keyterm_stays_one_term(): + """A phrase must arrive as ONE keyterm value, not split into two params. + Deepgram treats a single keyterm as one cohesive unit; splitting it changes + what is being prompted for.""" + pairs = _pairs( + await _handshake_path({"model": "nova-3", "keyterms": ["cerebral palsy"]}) + ) + assert [v for k, v in pairs if k == "keyterm"] == ["cerebral palsy"] + + +@pytest.mark.asyncio +async def test_denied_params_never_reach_the_wire(): + """`callback` is an exfil primitive and `keywords` is an unsupported + Nova-2-only param. Neither may appear on the URL, including via `extra`.""" + params = { + "model": "nova-3", + "callback": "https://evil.example/collect", + "keywords": "term:2", + "extra": {"callback": "https://evil.example/collect2", "keywords": "x:3"}, + } + keys = [k for k, _ in _pairs(await _handshake_path(params))] + assert "callback" not in keys + assert "keywords" not in keys + + +@pytest.mark.asyncio +async def test_base_url_is_never_forwarded_to_deepgram(): + """`base_url` selects the host client-side. Forwarding it as a query param + would leak internal endpoint names into Deepgram's request logs.""" + path = await _handshake_path({"model": "nova-3", "base_url": "api.deepgram.com"}) + assert "base_url" not in [k for k, _ in _pairs(path)] From 14fc5b6c92e2bb56345e7753096d63510fdbb37e Mon Sep 17 00:00:00 2001 From: Jacob Lasky Date: Sat, 25 Jul 2026 13:04:04 -0400 Subject: [PATCH 4/4] test(ui): commit the DOM probe that UI review questions require MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /sr-dev-review Q5 requires a real-browser artifact for any change touching templates/index.html or static/app.js, on the grounds that a passing suite says nothing about the rendered DOM — contract tests assert on fixtures we wrote ourselves, so they pass while the page differs. There was no harness for that, so every UI change re-invented the setup ad hoc. This is that harness. uv run python scripts/probe_ui.py --expect-present Keyterms --expect-absent Keywords Expands every collapsible param section before asserting, because a field can look absent purely because its accordion was shut. Checks an absent field three ways — rendered label, x-model binding, and live Alpine params state — since a removed label with a live binding still ships the param. Exits non-zero on any unmet expectation, so it works as a gate and not just as a screenshot printer; verified it fails on an inverted expectation rather than only ever passing. playwright goes in the dev group. CI installs the pip package only; the browser binary stays an opt-in `uv run playwright install chromium`, so the test job does not get slower. Artifacts land in gitignored scripts/probe-artifacts/. --- .gitignore | 1 + pyproject.toml | 5 ++ scripts/probe_ui.py | 124 ++++++++++++++++++++++++++++++++++++++++++++ uv.lock | 51 ++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 scripts/probe_ui.py diff --git a/.gitignore b/.gitignore index ee88d90..5b5f9fb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ outputs/* # Generated evidence from scripts/. Regenerable; keep deliberately, not by accident. scripts/*.csv +scripts/probe-artifacts/ # Claude Code local overrides .claude/settings.local.json diff --git a/pyproject.toml b/pyproject.toml index bcd75eb..00256d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,11 @@ dev = [ # [project] dependencies: the async app must never ship the sync # websocket-client path again (see test_websocket_client_not_a_runtime_dep). "websocket-client>=1.9.0", + # scripts/probe_ui.py only. A UI change needs a real-browser artifact; a + # passing suite says nothing about the rendered DOM. The pip package is all + # CI installs — the browser binary is a separate opt-in + # (`uv run playwright install chromium`), so this does not slow the test job. + "playwright>=1.61.0", ] [tool.pytest.ini_options] diff --git a/scripts/probe_ui.py b/scripts/probe_ui.py new file mode 100644 index 0000000..303bd7c --- /dev/null +++ b/scripts/probe_ui.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Visual/DOM probe for UI changes: proves a field rendered, or is really gone. + +WHY THIS EXISTS: a passing test suite is not evidence about the DOM. Contract +tests assert on fixtures whose shape we wrote ourselves, so they pass while the +rendered page differs. Any change to templates/index.html or static/app.js needs +an artifact from a real browser, and without a committed harness every such +change re-invents this setup from scratch. + +Usage: + # Terminal 1 + DEEPGRAM_API_KEY=probe-key uv run uvicorn app:app --port 8899 + # Terminal 2 + uv run python scripts/probe_ui.py + uv run python scripts/probe_ui.py --expect-absent Keywords --expect-present Keyterms + +Requires the browser binary once: `uv run playwright install chromium`. + +Writes screenshots next to this script under probe-artifacts/, which is +gitignored: attach them to the PR, do not commit them. +""" +import argparse +import asyncio +import json +import sys +from pathlib import Path + +from playwright.async_api import async_playwright + +ARTIFACTS = Path(__file__).resolve().parent / "probe-artifacts" + + +async def probe(url: str, expect_present: list[str], expect_absent: list[str]) -> int: + ARTIFACTS.mkdir(exist_ok=True) + failures: list[str] = [] + + async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page(viewport={"width": 1500, "height": 1400}) + + console_errors: list[str] = [] + page.on("console", lambda m: m.type == "error" and console_errors.append(m.text)) + page.on("pageerror", lambda e: console_errors.append(f"pageerror: {e}")) + + try: + await page.goto(url, wait_until="networkidle") + except Exception as exc: + # A raw ERR_CONNECTION_REFUSED traceback reads like a probe bug. It + # almost always means the app is not running; say so. + await browser.close() + print(f"could not load {url}: {exc}", file=sys.stderr) + print( + "\nIs the app running? Start it with:\n" + " DEEPGRAM_API_KEY=probe-key uv run uvicorn app:app --port 8899", + file=sys.stderr, + ) + return 1 + await page.wait_for_timeout(1200) + + # Expand every collapsible param section, or a field can be "absent" + # only because its accordion happened to be shut. + await page.evaluate("""() => { + const d = Alpine.$data(document.querySelector('[x-data]')); + Object.keys(d.sections || {}).forEach(k => d.sections[k] = true); + }""") + await page.wait_for_timeout(700) + + labels = await page.eval_on_selector_all( + ".field-label", "els => els.map(e => e.textContent.trim())" + ) + bindings = await page.eval_on_selector_all( + "[x-model]", "els => els.map(e => e.getAttribute('x-model'))" + ) + params = await page.evaluate( + "() => JSON.parse(JSON.stringify(" + "Alpine.$data(document.querySelector('[x-data]')).params))" + ) + + print(f"fields rendered: {len(labels)}") + print(json.dumps(sorted(labels), indent=1)) + print(f"\nconsole errors: {console_errors or 'none'}") + if console_errors: + failures.append(f"console errors on load: {console_errors}") + + for name in expect_present: + if name not in labels: + failures.append(f"expected field {name!r} to render; it did not") + + for name in expect_absent: + # Check the label, the x-model binding, AND the live Alpine state. + # A removed label with a live binding left behind still ships the + # param, which is the failure this triple-check catches. + key = name.lower() + if name in labels: + failures.append(f"{name!r} still has a rendered label") + if f"params.{key}" in bindings: + failures.append(f"{name!r} still has an x-model binding") + if key in params: + failures.append(f"{key!r} is still in the live Alpine params state") + + await page.screenshot(path=str(ARTIFACTS / "ui_full.png"), full_page=True) + print(f"\nartifact: {ARTIFACTS / 'ui_full.png'}") + await browser.close() + + if failures: + print("\nFAILED:") + for f in failures: + print(f" - {f}") + return 1 + print("\nOK: all DOM expectations met") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--url", default="http://127.0.0.1:8899/") + ap.add_argument("--expect-present", nargs="*", default=[]) + ap.add_argument("--expect-absent", nargs="*", default=[]) + a = ap.parse_args() + return asyncio.run(probe(a.url, a.expect_present, a.expect_absent)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 43a936d..00d32b3 100644 --- a/uv.lock +++ b/uv.lock @@ -197,6 +197,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "aiohttp" }, + { name = "playwright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "websocket-client" }, @@ -219,6 +220,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "aiohttp", specifier = ">=3.9.0" }, + { name = "playwright", specifier = ">=1.61.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.23,<1.0" }, { name = "websocket-client", specifier = ">=1.9.0" }, @@ -265,6 +267,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -365,6 +385,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "playwright" +version = "1.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" }, + { url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -460,6 +499,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.19.2"