fix(params): send keyterm, not keyterms — and stop silently dropping 6 more params#8
Merged
Merged
Conversation
…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/.
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
TypeErrorbefore any network call, killing the stream.scripts/test_rrhaphy_keyterms.pyran 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-sdkenumerates only ~28 of Deepgram's query params as keyword arguments and raisesTypeErroron 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_kwargsnow splits params into named kwargs andRequestOptions.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
keytermandkeywordsare different features, not two names for one thing:keyterm, Nova-3 and Flux) improves Keyword Recall Rate and takes bare terms only. No intensifier syntax —keyterm=term:2prompts for the literal stringterm:2.keywords, Nova-2 and older) is the boosting feature,keywords=TERM:INTENSIFIER.Verified against
deepgram-docsfern/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.keywordsis 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 fromconfig/defaults.json, the Alpine state, and the template, and added toDENIED_PARAMSso it cannot arrive viaextraeither.Two more defects found in review
keytermsand an explicitextra={"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 unioningmodelwould put a list where a string belongs._is_blocked()now checks both names, and a test monkeypatches an alias ontocallbackto guard the invariant rather than today's alias table.Live verification
tests/test_wire_contract.pyis 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:
Repro proof. These tests were confirmed to fail on the pre-fix code, with the reported symptom rather than an adjacent one:
11 tests fail with the alias and the kwarg split reverted; all pass with them restored. The helper deliberately re-raises
TypeErrorinstead 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-modelbinding, and nokeywordskey in the live Alpine params state — with zero console errors. The URL preview emitskeyterm=perineorrhaphy&keyterm=cerebral%20palsy: singular, repeated per term, multi-word phrase preserved as one term.That probe is committed as
scripts/probe_ui.pyso 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.