Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,50 @@ def _safe_temp_path(filename: str) -> Path:
app = socketio.ASGIApp(sio, fastapi_app)

def _clean_error(e: Exception) -> str:
"""Strip SDK request headers (including auth token) from Deepgram exception messages."""
"""Turn a Deepgram failure into the most specific message we can safely show.

DO NOT weaken the header stripping below. SDK exception messages embed the
full request, Authorization header included, so returning str(e) verbatim
would put the server's API key in an HTTP response body.

For httpx failures we substitute Deepgram's RESPONSE body, which is where the
actual reason lives. str(HTTPStatusError) is only
Client error '400 Bad Request' for url '...'
For more information check: https://developer.mozilla.org/...
which names neither the offending parameter nor the reason, and an MDN link
to "what is a 400" is worse than useless in a diagnostic tool: the whole
point of this app is to tell someone WHY Deepgram rejected their request.
A response body is safe to surface — credentials travel in request headers.
"""
if isinstance(e, httpx.HTTPStatusError):
status = e.response.status_code
try:
body = e.response.json()
detail = body.get("err_msg") or body.get("error") or body.get("message")
code = body.get("err_code")
if detail:
return f"Deepgram {status}: {detail}" + (f" ({code})" if code else "")
return f"Deepgram {status}: {json_mod.dumps(body)[:500]}"
except ValueError:
text = (e.response.text or "").strip()
return f"Deepgram {status}: {text[:500]}" if text else f"Deepgram {status}"

if isinstance(e, websockets.exceptions.ConnectionClosed):
# Deepgram rejects an unsupported parameter on a STREAM by completing the
# handshake and then closing with code 1000 and NO reason, so websockets
# reports only "received 1000 (OK); then sent 1000 (OK)". That names
# neither the parameter nor the cause, and it reads like a clean shutdown
# rather than a rejection. Verified live: alternatives=2 on nova-3 closes
# this way, while the same request in batch mode returns a 400.
return (
f"Deepgram closed the stream immediately without transcribing ({e}). "
"A close with no reason is what an unsupported parameter looks like on "
"a stream — most often a parameter the chosen model does not accept "
"(e.g. alternatives>1 on nova-*, which is valid on base and enhanced). "
"Re-run the same parameters in batch mode: there Deepgram returns a "
"400 stating the actual reason."
)

msg = str(e)
m = re.search(r'status_code:\s*(\d+),\s*body:\s*(.+)$', msg, re.DOTALL)
if m:
Expand Down Expand Up @@ -696,7 +739,9 @@ async def _batch_pipeline():
return JSONResponse({"batch": batch_result, "streaming": stream_result})

except httpx.HTTPStatusError as e:
return JSONResponse({"error": str(e)}, status_code=e.response.status_code)
# _clean_error, never str(e): it surfaces Deepgram's reason and strips
# request headers. This handler used str(e) and returned an MDN link.
return JSONResponse({"error": _clean_error(e)}, status_code=e.response.status_code)
except Exception as e:
return JSONResponse({"error": _clean_error(e)}, status_code=500)

Expand Down
104 changes: 82 additions & 22 deletions scripts/probe_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
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
uv run python scripts/probe_ui.py --expect-removed Keywords --expect-present Keyterms
uv run python scripts/probe_ui.py --mode streaming \
--expect-absent "Filler Words" Utterances Paragraphs

Exits non-zero when an expectation is unmet, so it works as a gate rather than
just a screenshot printer.

Requires the browser binary once: `uv run playwright install chromium`.

Expand All @@ -30,7 +35,18 @@
ARTIFACTS = Path(__file__).resolve().parent / "probe-artifacts"


async def probe(url: str, expect_present: list[str], expect_absent: list[str]) -> int:
def _param_key(label: str) -> str:
"""UI label -> Alpine params key. "Filler Words" -> "filler_words"."""
return label.strip().lower().replace(" ", "_")


async def probe(
url: str,
expect_present: list[str],
expect_absent: list[str],
expect_removed: list[str],
mode: str | None,
) -> int:
ARTIFACTS.mkdir(exist_ok=True)
failures: list[str] = []

Expand Down Expand Up @@ -58,19 +74,36 @@ async def probe(url: str, expect_present: list[str], expect_absent: list[str]) -
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("""() => {
# only because its accordion happened to be shut. Some params are
# mode-gated (batch-only features are hidden while streaming), so --mode
# picks which surface is being asserted.
await page.evaluate(
"""(m) => {
const d = Alpine.$data(document.querySelector('[x-data]'));
if (m) d.mode = m;
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'))"
}""",
mode,
)
await page.wait_for_timeout(700)
if mode:
print(f"mode: {mode}")

# VISIBLE labels only, and both label flavours (field rows use
# .field-label, checkboxes use <label for>). A mode-gated control is
# still in the DOM with x-show false, so presence is not the question —
# whether the user can see it is.
labels = await page.evaluate("""() => {
const seen = document.querySelectorAll('.field-label, .checkbox-item label');
return [...seen]
.filter(e => e.offsetParent !== null)
.map(e => e.textContent.trim());
}""")
bindings = await page.evaluate("""() => {
return [...document.querySelectorAll('[x-model]')]
.filter(e => e.offsetParent !== null)
.map(e => e.getAttribute('x-model'));
}""")
params = await page.evaluate(
"() => JSON.parse(JSON.stringify("
"Alpine.$data(document.querySelector('[x-data]')).params))"
Expand All @@ -86,20 +119,34 @@ async def probe(url: str, expect_present: list[str], expect_absent: list[str]) -
if name not in labels:
failures.append(f"expected field {name!r} to render; it did not")

# Two distinct kinds of absence, deliberately NOT conflated:
# --expect-absent not visible to the user (e.g. a batch-only control
# while streaming). The param legitimately stays in
# Alpine state; only the control is hidden.
# --expect-removed gone entirely — no label, no binding, and not in
# state. A removed label with a live binding left
# behind still ships the param, which is the failure
# the state check catches.
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()
key = _param_key(name)
if name in labels:
failures.append(f"{name!r} is still visible")
if f"params.{key}" in bindings:
failures.append(f"{name!r} still has a visible x-model binding")

for name in expect_removed:
key = _param_key(name)
if name in labels:
failures.append(f"{name!r} still has a rendered label")
failures.append(f"{name!r} is still visible")
if f"params.{key}" in bindings:
failures.append(f"{name!r} still has an x-model binding")
failures.append(f"{name!r} still has a visible 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'}")
# Mode in the filename so a streaming run does not overwrite a batch one.
shot = ARTIFACTS / f"ui_{mode or 'default'}.png"
await page.screenshot(path=str(shot), full_page=True)
print(f"\nartifact: {shot}")
await browser.close()

if failures:
Expand All @@ -115,9 +162,22 @@ 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=[])
ap.add_argument(
"--expect-absent", nargs="*", default=[],
help="not visible to the user; may still exist in Alpine state (mode-gated)",
)
ap.add_argument(
"--expect-removed", nargs="*", default=[],
help="gone entirely: no label, no binding, and not in Alpine state",
)
ap.add_argument(
"--mode", default=None,
help="set the app mode first (streaming|batch); some controls are mode-gated",
)
a = ap.parse_args()
return asyncio.run(probe(a.url, a.expect_present, a.expect_absent))
return asyncio.run(
probe(a.url, a.expect_present, a.expect_absent, a.expect_removed, a.mode)
)


if __name__ == "__main__":
Expand Down
49 changes: 45 additions & 4 deletions stt/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,50 @@ class Mode(str, Enum):
BOTH = "both"


# Params that are ONLY valid in streaming mode
STREAMING_ONLY = {"interim_results", "vad_events", "endpointing", "utterance_end_ms", "no_delay"}
# Mode gating, taken from Deepgram's own docs capability matrix. Each STT feature
# page carries machine-readable markers, e.g. filler-words.mdx has
# <Markdown src="/snippets/stt-batch-available.mdx" />
# <Markdown src="/snippets/stt-stream-unavailable.mdx" />
# so these two sets are transcribed from `stt-stream-unavailable` and
# `stt-batch-unavailable` respectively. DO NOT add a param here from memory:
# check the feature's page in deepgram-docs, because guessing wrong in either
# direction is invisible. Sending a batch-only param to streaming is SILENTLY
# IGNORED (the feature you think you are testing was never applied), and
# stripping a param that is actually valid is equally silent.
#
# Params that are ONLY valid in streaming mode (docs: stt-batch-unavailable)
STREAMING_ONLY = {
"interim_results",
"vad_events",
"endpointing",
"utterance_end_ms",
"no_delay",
"channels",
"encoding",
"sample_rate",
}

# Params that are ONLY valid in batch mode (docs: stt-stream-unavailable)
BATCH_ONLY = {
"paragraphs",
"topics",
"intents",
"sentiment",
"utterances",
"filler_words",
"measurements",
"utt_split",
"detect_language",
}

# Params that are ONLY valid in batch mode
BATCH_ONLY = {"paragraphs", "topics", "intents", "sentiment", "utterances"}
# Numeric params where the UI's default of 0 means "unset", not "zero". Both of
# these fields render as 0 in the params panel, and Deepgram returns 400 for
# `sample_rate=0` or `channels=0`, so forwarding the default broke every batch
# request that touched them.
#
# DO NOT generalise this to "drop all zeroes". `endpointing=0` is meaningful: it
# disables endpointing. That is why this is an explicit set and not a rule.
ZERO_MEANS_UNSET = {"sample_rate", "channels", "alternatives"}

# Params that should never be sent to Deepgram (handled by client)
INTERNAL_PARAMS = {"base_url"}
Expand Down Expand Up @@ -111,6 +150,8 @@ def clean_params(params: dict, mode: Mode) -> dict:
continue
if isinstance(value, bool) and not value:
continue
if wire in ZERO_MEANS_UNSET and value in (0, "0"):
continue
_put(result, wire, value)

# Handle extra params: merge into result. `extra` is the deliberate escape
Expand Down
13 changes: 10 additions & 3 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1285,7 +1285,11 @@
<input type="checkbox" id="numerals" x-model="params.numerals">
<label for="numerals">Numerals</label>
</div>
<div class="checkbox-item">
<!-- Batch only (docs: stt-stream-unavailable). Hidden rather than
shown-and-ignored: Deepgram drops a stream-unavailable param
silently, so a checkbox that does nothing reads as a broken
feature. stt/options.py BATCH_ONLY strips it server-side. -->
<div class="checkbox-item" x-show="mode === 'batch'">
<input type="checkbox" id="filler_words" x-model="params.filler_words">
<label for="filler_words">Filler Words</label>
</div>
Expand Down Expand Up @@ -1323,11 +1327,14 @@
<input type="checkbox" id="detect_entities" x-model="params.detect_entities">
<label for="detect_entities">Detect Entities</label>
</div>
<div class="checkbox-item">
<!-- Batch only (docs: stt-stream-unavailable), same reasoning as
Filler Words above: silently ignored on a stream, so hide it
rather than offer a control that cannot work. -->
<div class="checkbox-item" x-show="mode === 'batch'">
<input type="checkbox" id="utterances" x-model="params.utterances">
<label for="utterances">Utterances</label>
</div>
<div class="checkbox-item">
<div class="checkbox-item" x-show="mode === 'batch'">
<input type="checkbox" id="paragraphs" x-model="params.paragraphs">
<label for="paragraphs">Paragraphs</label>
</div>
Expand Down
66 changes: 58 additions & 8 deletions tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,30 @@ def test_removes_falsy_values():
assert result["model"] == "nova-3"


def test_keeps_zero_numerics():
# clean_params does NOT strip 0 — callers are responsible for omitting defaults
result = clean_params({"alternatives": 0, "channels": 0}, Mode.STREAMING)
assert result["alternatives"] == 0
assert result["channels"] == 0
def test_zero_is_dropped_only_where_zero_means_unset():
"""The UI renders sample_rate, channels and alternatives as 0 when unset, and
Deepgram returns 400 for sample_rate=0 / channels=0 — verified live. So 0 on
those is "omit", not "zero".

This test previously asserted the opposite ("callers are responsible for
omitting defaults"). No caller did, so the UI's own defaults produced a live
400 on every batch request that touched them.
"""
result = clean_params(
{"alternatives": 0, "channels": 0, "sample_rate": 0}, Mode.STREAMING
)
assert result == {}

# A non-zero value still goes through; only the sentinel is dropped.
result = clean_params({"channels": 2, "sample_rate": 16000}, Mode.STREAMING)
assert result == {"channels": 2, "sample_rate": 16000}


def test_zero_is_preserved_where_zero_is_meaningful():
"""DO NOT generalise the rule above to all zeroes. endpointing=0 disables
endpointing, so dropping it would silently change behaviour."""
result = clean_params({"endpointing": 0}, Mode.STREAMING)
assert result["endpointing"] == 0


def test_removes_false_booleans():
Expand Down Expand Up @@ -155,12 +174,43 @@ def test_sdk_accepts_every_serialized_param_name():
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"):
# Real Deepgram params the SDK does not enumerate; they must be routed, not
# lost. Regression guard for the keyterms crash class. filler_words is NOT in
# this list: Deepgram's docs mark it stream-unavailable, so it is stripped in
# streaming mode by design — see test_stream_unavailable_params_are_stripped.
for name in ("no_delay", "word_confidence", "diarize_version", "entity_prompt"):
assert name in via_query, f"{name} is not being forwarded"


def test_stream_unavailable_params_are_stripped_in_streaming():
"""Deepgram's docs mark these stream-unavailable, and a batch-only param sent
to a stream is SILENTLY IGNORED — so leaving them in meant the feature under
test was never applied and the run looked valid. Batch keeps them."""
from stt.options import clean_params, Mode
for name in ("filler_words", "measurements", "utt_split", "detect_language"):
assert name not in clean_params({name: True}, Mode.STREAMING), name
assert name in clean_params({name: True}, Mode.BATCH), name


def test_batch_unavailable_params_are_stripped_in_batch():
"""Docs mark these batch-unavailable. sample_rate and channels additionally
returned a live 400 from Deepgram when forwarded to batch."""
from stt.options import clean_params, Mode
for name, value in (("channels", 2), ("encoding", "linear16"), ("sample_rate", 16000)):
assert name not in clean_params({name: value}, Mode.BATCH), name
assert name in clean_params({name: value}, Mode.STREAMING), name


def test_alternatives_is_not_mode_gated():
"""`alternatives` is MODEL-dependent, not mode-dependent, and this distinction
cost a wrong fix. alternatives=2 returns 400 on nova-3/nova-2 but 200 on base
and enhanced, in BOTH batch and streaming (verified live). Mode-gating it
would break the legacy-model config replication this tool exists to do."""
from stt.options import clean_params, Mode
for mode in (Mode.BATCH, Mode.STREAMING):
assert clean_params({"alternatives": 2}, mode) == {"alternatives": 2}


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
Expand Down
Loading