fix(stt): type-check the config PUT's model and provider before the lookup - #5993
Conversation
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of The fix is verified against the base: Design-Verdict: PASS Real 500-on-client-error fixed at its root cause, in the pattern the sibling fields already use, with the endpoint's contract deliberately unchanged. [DESIGN-REVIEWED] 73fcb5f |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe diff is straightforward and sound: it adds No findings. [OPUS-REVIEWED] 73fcb5f |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All claims verified against the base. Composing the review now. First-Principles-Verdict: PASS A real 500-on-request crash fixed at its cause — the collapsed type-plus-allowlist test — with the last crashing sibling covered and zero new surface. What this change shipsIntent: stop a wrong-typed
The fix restores the exact two-step spelling already used at Watch
[FIRST-PRINCIPLES-REVIEWED] 73fcb5f |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
564878b to
23104b4
Compare
23104b4 to
73fcb5f
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix. Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix (2 files, root cause: STT config PUT did a dict-membership lookup on unhashable/non-string model/provider, turning a partial update into a 500). CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: the STT config PUT looked up body["model"] / body["provider"] in a dict catalog without a type guard, so a JSON object or array field raised TypeError: unhashable type and turned a partial update into a 500 -- an isinstance guard now precedes each membership lookup, keeping the existing skip-invalid-field-apply-valid-siblings contract, with 121 lines of field-type tests. Hardens an existing parse path; introduces no new deserialization. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: isinstance guard added before two dict-membership lookups on an existing PUT body, so a JSON object or array raises no unhashable-type 500; skip-the-field-keep-valid-siblings follows the existing config contract, no new field or endpoint is consumed. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
Problem / Motivation
PUT /api/config/sttapplies thirteen optional fields. Eleven of themtype-check the value before using it. Two do not — and one of those two is a
dict-key lookup:
mlx_modelandparakeet_modelsit immediately belowmodelwith anidentically-shaped membership test and do guard it. So do
transcribe_region,transcribe_profile,language_code(isinstance(..., str))and
streaming,endpointing,dictation_panel(isinstance(..., bool))._STT_MODEL_SIZESis declareddict[str, str](core.py:480), sobody["model"] in _STT_MODEL_SIZEShashes the caller's value.{"model": {"size": "small"}}or{"model": ["small"]}therefore raisesTypeError: unhashable typeout of the handler and reaches the client as a500, where every guarded sibling would have quietly ignored the field.
Why it matters
The dashboard's Settings pane and anything else that speaks this API get a
server fault for a client mistake: an unhandled
TypeErrorwith a stack tracein the log, an opaque 500 on the wire, and no way for a caller to tell "I sent
the wrong type" from "the gateway is broken". The eleven guarded fields on the
same endpoint answer the same mistake with a 200 and an ignored field, so today
the failure semantics depend on which key you got wrong.
Scope of the damage is worth stating precisely, because it is narrower than the
500 suggests: the raise lands inside
_get_config_lock(), afterconfig.jsonhas been read and before
_atomic_json_write, so nothing is persisted and thelock releases normally. This is a availability/ergonomics defect on the request,
not a torn config — the tests assert the stored section is byte-identical after
a rejected PUT.
What changed (motivation → approach → change)
The root cause is that the type check and the allowlist check were collapsed
into one membership test, which works only for values that are hashable. Adding
the
isinstancerestores the two-step form the other eleven fields use:modelandprovidergetisinstance(body[...], str)before the membershiptest, in the same
and-chain shape asmlx_model/parakeet_model.eleven siblings — not a 400. A 400 here would be a behaviour change for every
other field's contract on this endpoint, and a much larger review surface than
the defect warrants.
provideris checked against alist, so nothing raises there on main today.It is guarded anyway: it is
model's immediate neighbour under the samecontract, and guarding only
modelwould re-create the arbitrary split thisPR exists to remove. It also removes the latent 500 the moment
_stt_providers()returns a set or a dict.Deliberately not changed:
stt["enabled"] = bool(body["enabled"])two linesabove.
bool()never raises, but it does silently accept anything —{"enabled": "false"}currently enables STT, while thestreaming/endpointing/dictation_panelsiblings require a real bool. That is a realinconsistency, but fixing it would start rejecting clients that legitimately
send
1or"true"today, which is a compatibility decision rather than acrash fix and belongs in its own PR.
Tests
New
test/test_stt_config_field_types.py, mirroring the harness intest_stt_config_atomic.py(aMagicMock(spec=web.Request)PUT, with the GETtail's host probes neutralised).
The fixture stubs
ensure_ffmpeg_in_pathas well as_stt_prereq_commandsandis_available, and that third one is there for a different reason: it does notmerely read the host, it writes
os.environ["PATH"](
transcribe.py:os.environ["PATH"] = d + os.pathsep + ...) whenever acandidate directory holds an ffmpeg that PATH does not already list, and never
restores it. Left live, thirteen requests through this handler would hand every
later test in the pytest worker a mutated PATH.
test_the_fixture_neutralises_the_path_mutating_probeasserts the stub isinstalled by identity rather than by comparing PATH before and after — a
PATH-equality check passes vacuously on a host whose ffmpeg is already on PATH
or absent from every candidate dir, so it would not notice the stub being
dropped. Without the stub that guard fails on any host
(
assert <function ensure_ffmpeg_in_path> is not <function ensure_ffmpeg_in_path>).Note for the reviewer: the sibling fixture in
test_stt_config_atomic.pyhasthe same gap and is left alone — it is pre-existing and not this diff's to
change.
Each test seeds a known-good stt section with a valid PUT first, snapshots it,
then sends the wrong-typed PUT and asserts the stored section is unchanged.
The seeding matters: the first successful PUT is also what materializes
config.jsonwith its defaults, so a snapshot taken on a fresh home would becomparing a missing file against a defaulted one and would pass or fail for
reasons unrelated to the change.
The defect, and the controls, are labelled as such rather than presented as one
uniform block of coverage:
test_unhashable_model_is_ignored_not_a_500—{"size": "small"},["small"],[{"size": "small"}]. This is the defect.test_a_wrong_typed_field_does_not_discard_the_valid_ones— a dictmodelalongside a valid
language_code; the good field must still land.test_other_wrong_typed_model_values_stay_ignored—3,True,None.Control: these are hashable, so main already rejects them; pinned so the
new guard cannot change what "unknown model" does.
test_non_string_provider_is_ignored— control for the same reason;_stt_providers()is a list, so none of these raise on main.test_valid_model_and_provider_still_persistandtest_an_unknown_but_well_typed_model_is_still_ignored— controls thatthe guard neither rejects good values nor changes the unknown-value path.
Red-before, measured against pristine
origin/mainproduction code(
c7f5ba788) with the new file in place — 4 failed / 9 passed. Everyfailure is the defect, at the line the diff changes, and the nine controls pass
exactly as they should:
Green-after: 14 passed. Blast radius: 320 passed / 18 skipped across
test_stt_config_field_types.py,test_stt_config_atomic.py,test_stt_endpointing.py,test_stt_stream.py,test_stt_dictation_panel.py,test_dashboard_handlers_core_coverage.pyandtest_transcribe.py.flake8,isort,mypyandblackare all clean on both files —handlers/core.pyis not on.github/black-baseline.txt, so it is checkedstrictly, and it stays unchanged under
black --check.Manual verification
N/A — unit coverage sufficient: the defect is a request-body type contract on a
single handler, and the tests drive that handler directly with the exact JSON
shapes that reproduce it, then read the persisted config back.
Related Issues
Self-reported while auditing membership tests against unvalidated JSON values.
Same defect class as #5977 (
api_channel_post'smention/thread_id), in adifferent subsystem; the two do not overlap and neither depends on the other.
No separate issue was filed.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement