fix(workspace): the /stt provider error says why, instead of one opaque 422 (#2696) - #2702
Conversation
Review —
|
Re-review — [C1] fixed
Re-drove the classifier over the full table — all eleven rows, including the two that were wrong: Note the last column: the operator still gets the sentence, it is just no longer mistaken for a verdict. Coverage. Five rows added to the mapping table (three prose-shaped auth failures, plus two where a token decides despite quota words in the prose around it), and three focused tests — the token/prose split, the rule itself, and that the operator detail still falls back to prose. Verified in the backend image rather than claimed: 38 passing in this suite, 66 with #2695's alongside. Checked for the same class elsewhere: No new failures: the [I1] (client sentences disclosing the operator's billing state to an external Workspace customer) and [I2] ( |
|
merge-train: not on today's train, and not because of anything in this PR. It is stacked on #2699, which was held back for the cross-worker cache staleness in Validation came back clean. The leak question in particular: the portal principal receives only Two things to decide before it ships, since both are product calls rather than defects:
One mechanical note for merge time: |
|
…ue 422 (#2696) `transcribe_portal_audio` mapped every non-200 from ElevenLabs onto `422 "Could not transcribe the audio"`. A missing endpoint permission, a rejected key, exhausted credits, a provider rate limit and a rejected audio container all read identically, while the actionable status word sat in a backend WARNING one line above — a live instance cost an operator with container access a full round-trip to answer a question the system already knew. `stt_capability_service.classify_stt_failure()` (pure) maps a provider answer onto a named category with its own client status and sentence: permission / auth / quota 401·402·403 by status word → 503, operator-actionable rate_limit 429 → 429, the existing retry wording audio 400·413·415·422 → 422, "the recording could not be read" provider 5xx → 502, the existing transport wording unknown anything else → 502, still says who failed No arm returns the old string; a test sweeps every status 300-599 to pin that, so an unrecognised provider answer cannot regress to it. The client sentence never carries the provider body. `record_live_failure` keeps the status word + category for THIS key (`stt:last_failure:<digest>`, 24h; per-process fallback) and still feeds #2695's capability cache on a 401/403, so the mic hides on the next load. `GET /api/settings/elevenlabs` (admin-only) carries it as `stt_last_failure`, and Settings -> Voice renders "Last voice-input failure: <why> (HTTP <status> <word>) — <time>" under the capability badge. Fail-soft is unchanged: every branch raises ClientPortalError, never a 500, and the client can always type instead. Fixes #2696 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…#2696) Review finding, reproduced by driving the classifier: a 401 whose body carries no status token was classified `quota` whenever its SENTENCE happened to contain one of `_QUOTA_WORDS`. 401 {"detail":"Invalid API key for your plan"} -> quota 401 {"detail":{"message":"…not valid for this subscription"}} -> quota Both are ordinary auth failures. The client was told "the account is out of credits or not on a plan that allows it" and the admin panel said the same, so an operator whose key simply needed replacing was sent to a billing page. That is worse than the opaque 422 this issue replaces — it is confidently wrong in a direction someone acts on. The cause is that `provider_status_word` collapsed two different things: `det["status"]`/`det["code"]`, which are machine tokens the provider documents, and `det["message"]`/a bare string detail, which is prose written for a human. The comment above `_QUOTA_WORDS` already claimed matching happened on "the status token"; the code matched whatever came back. `provider_status_parts(body) -> (token, prose)` keeps them apart, and `classify_stt_failure` matches ONLY the token. A 401/403 carrying prose alone is now `auth` — the honest reading of "the key was rejected and the provider did not say why". `provider_status_word` stays as the operator-facing display value (token else prose), so nothing is lost from the panel: the prose still shows, it just no longer votes. Verified in the backend image: 38 passing in this suite (5 new table rows + 3 new tests), 66 with #2695's alongside it, and the portal/settings sweep has the identical non-passing set on this branch and on its base — no new failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… provider-error branch Re-applied after rebasing onto dev: #2699 squash-merged with its own final wording of this bullet, so the branch's copy of the paragraph conflicted and dev's was taken; this is #2696's one addition to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… and invalidate forgets it (#2696) The /review on #2702 (I1): `read_last_failure` fell through to this worker's local copy on a Redis MISS — the pre-#2695-fix shape of `read_cached`, which that PR was held for. After the rebase the two readers in one module disagreed about what a miss means; now they share the rule (Redis answers ⇒ authoritative, miss evicts the local copy; local only when Redis cannot be asked). `invalidate` also drops the key's last-failure row, so re-saving a fixed key does not show the operator the pre-fix failure for 24h beside a fresh `capable` verdict. Mutation: restoring the fallthrough turns the new two-worker test red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
1fcc6ed to
32badbe
Compare
|
Rebuilt on
The only source delta against the old tip outside those is |
|
…2702) — mechanical, per the merge-train note on the PR `record_live_failure` stores the provider's status word / prose as the operator `detail` for 24h and the Settings panel renders it. The client half is a constant, so this row is the only place a provider body reaches any surface; the panel promises the key is never echoed, so enforce it here with `scrub_secret` instead of assuming it of the provider. One test drives a body carrying the key and asserts neither the returned failure nor the stored row contains it (mutation: scrub removed → red). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
|
merge-train: pushed to this branch — one commit, mechanical, per the review above (I1).
|
…ers/settings/ dev changed the deleted monolith twice since the last merge (0bddbc9): | dev hunk | now lives in | |---|---| | #2836 (ent#438) retire `workspace_available` + its docstring bullet | `routers/settings/flags.py` | | #2702 (#2696) `describe(cap, api_key=...)` + comment | `routers/settings/integrations.py` | dev's copy of `routers/settings.py` is removed, as in the previous merge. The #2836 port is load-bearing: dev removed `settings_service.is_workspace_enabled`, so the unported `flags.py` would raise AttributeError on GET /api/settings/feature-flags whenever voice is available. Checked with an AST pass: all 79 functions in dev's monolith exist in the package. Only three bodies differ, and those are the split's own cross-module references (`credentials.mask_api_key`, and `credentials._ANTHROPIC_KEY_ALIASES` / `_adopt_after_instance_key_removed`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
transcribe_portal_audiomapped every non-200 from ElevenLabs onto422 "Could not transcribe the audio". Missing permission, rejected key, exhausted credits, provider rate limit and a rejected audio container all read identically, while the status word sat in a backend WARNING.What changes
classify_stt_failure(status, body)(pure, instt_capability_service) — one named category per provider condition:permission/auth/quotarate_limitaudioproviderunknownNo arm returns the old string;
test_there_is_no_arm_that_returns_the_opaque_stringsweeps every status 300–599.Client never sees the provider body — only the category sentence.
record_live_failurekeeps status word + category understt:last_failure:<sha256(key)[:16]>(24h) and still feeds bug(workspace): the mic renders on ElevenLabs key presence, not STT capability — a key without speech_to_text gives a control that fails every time #2695's capability cache on 401/403.Operator surface —
GET /api/settings/elevenlabs(admin-only route) carriesstt_last_failure {category, provider_status, detail, at}; Settings → Voice renders "Last voice-input failure: the key is missing the speech-to-text permission (HTTP 401 missing_permissions) — " under the capability badge (utils/sttCapability.js::describeSttLastFailure, pure + tested).Frontend
transcriptionErrorMessage()already prefers the serverdetail, so the new sentences reach the user with no composer change.Acceptance criteria
test_categories_are_distinguishable_by_their_client_sentencetest_rate_limit_maps_to_429_with_the_existing_retry_wordingtest_client_message_never_carries_the_provider_body,test_the_portal_card_carries_no_operator_detail,test_settings_state_carries_the_last_failure_for_admins_only_by_routeunknowncategoryCoverage
Endpoint tests execute
transcribe_portal_audiowith the provider stubbed athttpx.AsyncClient(6 status cases + success + operator memory); the classifier is driven directly. No source-text assertions.Verification
Fixes #2696
🤖 Generated with Claude Code
https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht