Skip to content

fix(params): send keyterm, not keyterms — and stop silently dropping 6 more params#8

Merged
Jacob-Lasky merged 4 commits into
mainfrom
fix/keyterm-param-name
Jul 25, 2026
Merged

fix(params): send keyterm, not keyterms — and stop silently dropping 6 more params#8
Jacob-Lasky merged 4 commits into
mainfrom
fix/keyterm-param-name

Conversation

@Jacob-Lasky

Copy link
Copy Markdown
Owner

Streaming is broken in production for seven real Deepgram parameters. This fixes that, and adds the wire-level test that would have caught it.

The bug

Reported live:

AsyncV1Client.connect() got an unexpected keyword argument 'keyterms'

Deepgram's parameter is the singular keyterm, repeated once 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 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. Those CSVs are void, and "keyterms don't help" was never testable from them.

Fixed at the single parameter gate (PARAM_ALIASES), so every call site — streaming, batch, raw, URL builder — is corrected at once.

Six more params were already broken the same way

deepgram-sdk enumerates only ~28 of Deepgram's query params as keyword arguments and raises TypeError on anything else. A guard test comparing our emitted params against the SDK's actual signature found six more the UI exposes and the SDK does not name:

filler_words, no_delay, word_confidence, alternatives, diarize_version, entity_prompt

_params_to_sdk_kwargs now splits params into named kwargs and RequestOptions.additional_query_parameters. The accepted-name set is computed from the installed SDK, never hardcoded, so it widens on its own when a newer SDK starts naming them.

Terminology, corrected with teeth

keyterm and keywords are different features, not two names for one thing:

  • Keyterm Prompting (keyterm, Nova-3 and Flux) improves Keyword Recall Rate and takes bare terms only. No intensifier syntax — keyterm=term:2 prompts for the literal string term:2.
  • Keywords (keywords, Nova-2 and older) is the boosting feature, keywords=TERM:INTENSIFIER.

Verified against deepgram-docs fern/pages/speech-to-text/custom-vocabulary/{keyterm,keywords}.mdx. The weight syntax had leaked onto keyterms in a test fixture (keyterms: ["hello:2", "world"]) and a harness comment called it "intensifier boost", so anyone copying that pattern silently got a useless term.

keywords is dropped entirely: this app targets Nova-3 and Flux, and a Nova-2-only param 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.

Two more defects found in review

  • 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 are now unioned, order-preserving and deduped. Single-valued params still take one value, because unioning model would put a list where a string belongs.
  • The deny list was enforced on the caller's param name, not the wire name. No live bypass existed, since no alias currently targets a denied param, but the invariant was unguarded. _is_blocked() now checks both names, and a test monkeypatches an alias onto callback to guard the invariant rather than today's alias table.

Live verification

tests/test_wire_contract.py is new and is the point of this PR. It 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 — the producer half only. The SDK could accept a kwarg and drop it and nothing would notice, which is exactly how this bug survived: present in our dict, absent on the wire. No Deepgram credential required.

Observed handshake path:

/v1/listen?keyterm=alpha+beta&keyterm=gamma&model=nova-3&tag=t1&tag=t2
  &filler_words=true&no_delay=true&word_confidence=true
  &alternatives=3&diarize_version=v2&entity_prompt=names

Repro proof. These tests were confirmed to fail on the pre-fix code, with the reported symptom rather than an adjacent one:

TypeError: AsyncV1Client.connect() got an unexpected keyword argument 'keyterms'
TypeError: AsyncV1Client.connect() got an unexpected keyword argument 'filler_words'

11 tests fail with the alias and the kwarg split reverted; all pass with them restored. The helper deliberately re-raises TypeError instead of swallowing it, so a regression names the user's error instead of "server never saw a handshake".

UI artifact. Playwright against the running app confirms the Keywords field is gone — no rendered label, no x-model binding, and no keywords key in the live Alpine params state — with zero console errors. The URL preview emits keyterm=perineorrhaphy&keyterm=cerebral%20palsy: singular, repeated per term, multi-word phrase preserved as one term.

That probe is committed as scripts/probe_ui.py so the next UI change doesn't re-invent it, and it exits non-zero on unmet expectations rather than just printing a screenshot.

Still unverified: no real-wire call to Deepgram itself. The key-mint API returned 503 while this was being built, so the six passthrough params are proven to reach the URL but not proven accepted by Deepgram's server. Worth one live run after merge.

104 passed, 1 skipped.

…rams

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.
…ords

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.
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.
/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/.
@Jacob-Lasky
Jacob-Lasky merged commit 5bdf377 into main Jul 25, 2026
1 check passed
@Jacob-Lasky
Jacob-Lasky deleted the fix/keyterm-param-name branch July 25, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant