Repository navigation
fix(voice): map ElevenLabs BYOK config errors, keep hosted failures reported #8521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
M3gA-Mind
merged 10 commits into
tinyhumansai:main
from
sanil-23:fix/voice-provider-error-handling
Nov 2, 2026
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
6a5a8df
fix(voice): map ElevenLabs payment/key-ID errors to user messages and…
a45e6b8
fix(voice): keep hosted ElevenLabs billing failures reported, neutral…
claude 6e151d7
fix(voice): recognise every backend ElevenLabs failure wrapper as hosted
claude e1183d0
docs(findings): link the voice-error PR and the backend fix
claude 2c98c52
fix(voice): scope BYOK matching to the ElevenLabs client, stop loggin…
claude d5a364b
Merge remote-tracking branch 'upstream/main' into fix/voice-provider-…
claude f05c2c1
fix(voice): move hosted ElevenLabs mapping to tinyhumans, stop re-rep…
M3gA-Mind 4bab368
style(tinyhumans): rustfmt the hosted-voice classifier test
M3gA-Mind 1b7c03c
fix(tinyhumans): only exempt the exact hosted-voice copy from reporting
claude 75b90d2
fix(voice): keep hosted request errors actionable, log an upstream ca…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # FINDINGS: voice-provider error handling (Workstream C) | ||
|
|
||
| Branch: `fix/voice-provider-error-handling` (GitHub #8502, #8503). PR: tinyhumansai/openhuman#8521. | ||
|
|
||
| ## Correction (second commit): hosted vs BYOK | ||
| CONTEXT.md treated `payment_required` as a user config problem. The backend source | ||
| (`tinyhumansai/backend` @ 6af8f69, read-only clone) shows that is wrong for the hosted routes: | ||
| - `src/services/elevenlabs/elevenlabsService.ts` always calls ElevenLabs with the **server's own** | ||
| `ELEVENLABS_API_KEY` (all 5 call sites) and wraps any upstream failure as | ||
| `BadRequestError("ElevenLabs request failed (<status>): <body>")` (line 107), i.e. a 400. | ||
| - `src/middlewares/errorHandler.ts` does not report 4xx to the backend's Sentry, so the client report is | ||
| the only signal anyone gets. | ||
|
|
||
| So **TAURI-RUST-15BM (TTS) and -15CT (STT) are TinyHumans' own ElevenLabs subscription with a failed | ||
| payment**, not the users'. Telling 44 + 17 users to "update billing" is wrong, and demoting the event | ||
| hides an outage from the only people who can fix it. The first commit did both. Now: | ||
| - Hosted failures (detected by the backend's `ElevenLabs request failed (` wrapping) are never matched by | ||
| `voice_provider_user_message` / `is_voice_provider_user_state_message`, so they stay Sentry errors. | ||
| - The `classify.rs` demotion from the first commit is reverted: `authed_json` reports them again. | ||
| - Hosted TTS (`openhuman-tinyhumans` `voice/reply_speech.rs`) uses `map_hosted_voice_error`. Only failures | ||
| of the backend's own ElevenLabs account or service (billing codes or 402, 401/403/412, 429, 5xx, read from | ||
| the wrapper's upstream status) become "The voice service is temporarily unavailable. Please try again | ||
| later."; request errors the user can fix (`voice_not_found`, text too long, other 4xx) keep their message. | ||
| The raw error was already reported by `authed_json`; the mapping logs only the category and upstream status, | ||
| and the backend-error classifier marks the exact neutral message as not reportable again. | ||
| - Hosted STT (`cloud_transcribe.rs`) is left raw: the RPC boundary is its only report site, and mapping | ||
| there would strip the detail ops needs. | ||
| - The BYOK handling is kept: the external STT/TTS providers use the user's own key, so for them | ||
| `payment_required` ("Update billing") and the key-ID message (TAURI-RUST-1635) are correct, and demoted. | ||
|
|
||
| **The real fix for 15BM / 15CT is operational**: settle the TinyHumans ElevenLabs subscription. The | ||
| backend now surfaces upstream billing/account refusals as a 503 and alerts on them | ||
| (tinyhumansai/backend#1438). | ||
|
|
||
| ## What I found | ||
| - The ElevenLabs HTTP clients live in the vendored `tinyinference-voice` crate (`external_stt.rs` / `external_tts.rs`), | ||
| which formats errors as `[voice-stt] elevenlabs error {status}: {body}` / `[voice-tts] ... `. Not edited (vendor). | ||
| The host wraps them in `crates/openhuman-core/src/voice/factory/{stt,tts}_providers.rs`. | ||
| - Hosted TTS (`POST /openai/v1/audio/speech`) goes `openhuman-tinyhumans/src/voice/reply_speech.rs` -> | ||
| `BackendClient::authed_json` -> `client/classify.rs::finish_authed_json`. Any non-2xx that is not 401, budget, or | ||
| transient goes to `report_error` (Sentry error) with only the body *shape*. | ||
| - Hosted STT goes `cloud_transcribe.rs` -> `tinyinference_voice::cloud::transcribe` (not `authed_json`); only 401 is | ||
| classified there. | ||
| - Existing convention: `core/observability*.rs` `expected_error_kind` -> `ExpectedErrorKind::ProviderUserState` | ||
| (logged at info, not sent to Sentry). | ||
|
|
||
| ## What I changed (net) | ||
| - `crates/openhuman-core/src/core/observability_voice_provider_errors.rs` (included into `observability.rs`): | ||
| - `voice_provider_user_message()`: BYOK `payment_required` -> "Your ElevenLabs subscription has a payment issue. | ||
| Update billing to continue."; "API key ID used as API key" -> "That looks like an ElevenLabs key ID, not a key. | ||
| Keys start with `sk_`. Re-enter the API key."; hosted wrapping -> `None`. | ||
| - `is_voice_provider_user_state_message()`: raw BYOK text and the two mapped messages (RPC re-report stays demoted). | ||
| - `map_voice_provider_error()` (BYOK). The hosted mapping lives in `openhuman-tinyhumans` (above). | ||
| - `expected_error_kind` returns `ProviderUserState` for the BYOK cases only. | ||
| - Mapping applied in `ExternalSttProvider`, `ExternalTtsProvider` (BYOK) and `reply_speech.rs` (hosted). | ||
| - Tests: `observability_error_classification_voice_provider_tests.rs` (BYOK payment, key ID, hosted TTS/STT not | ||
| claimed, hosted neutral mapping, mapped-message re-report, pass-through, no over-match). | ||
|
|
||
| ## TAURI-RUST-112J classification | ||
| **Backend / upstream, not a client defect, and probably not user credits.** Same route (`/openai/v1/audio/speech`), | ||
| 400, envelope `error,errorCode,success` (no `details`). The backend's low-credit answer on this route is | ||
| `BadRequestError('Insufficient budget')` (`controllers/inference/textToSpeech.ts:57`), which the client's existing | ||
| budget filter already keeps out of Sentry (present in 0.64.15), so a reported 400 here is most likely another | ||
| `ElevenLabs request failed (...)` upstream failure (voice not found, quota, the same billing problem). Unknown 400s | ||
| still report. To close it: read the body from one user's daily log (the error string carries the first 512 bytes), | ||
| or check whether 112J drops once the ElevenLabs billing is fixed. | ||
|
|
||
| ## Verification (limited, please read) | ||
| - `cargo check -p openhuman` fails on a **clean** `upstream/main` too (~400 errors): vendored submodules are pinned to | ||
| commits older than the API the host code uses (e.g. `tinychannels_bus::module_progressive::ChannelOutputEvent`). | ||
| This blocks in-tree `cargo check/test` for `openhuman`, `openhuman-tinyhumans` and `openhuman-embed`. vendor/ not | ||
| touched; needs submodule pins bumped upstream. | ||
| - The corrected `observability_voice_provider_errors.rs` was compiled verbatim in a scratch crate (stubbed | ||
| `tracing`) with the test file's real error strings: BYOK payment and key ID map, hosted TTS/STT are not | ||
| claimed and pass through, hosted maps to the neutral message, no over-match. All pass. The parts that need | ||
| `expected_error_kind` (the demotion asserts) and the wiring edits (`reply_speech.rs`, embed re-export, | ||
| `classify.rs` revert) were formatted with rustfmt and checked by reading, NOT compiled in-tree. | ||
| - Note: sccache `EAGAIN` occurs with default parallelism on this box; use `cargo check -j 12`. |
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
109 changes: 109 additions & 0 deletions
109
crates/openhuman-core/src/core/observability_error_classification_voice_provider_tests.rs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| use super::*; | ||
|
|
||
| /// TAURI-RUST-15BM: the hosted TTS route wrapping a refusal of the backend's | ||
| /// own ElevenLabs account. | ||
| const HOSTED_PAYMENT_TTS: &str = r#"POST /openai/v1/audio/speech failed (400 Bad Request): {"success":false,"error":"ElevenLabs request failed (401): {\"detail\":{\"status\":\"payment_required\",\"message\":\"Your subscription has a failed or incomplete payment\"}}","errorCode":"BAD_REQUEST"}"#; | ||
| /// TAURI-RUST-15CT: the same refusal on the hosted transcription route. | ||
| /// The backend's transcription wrapper differs from the TTS one. | ||
| const HOSTED_PAYMENT_STT: &str = r#"transcription request failed (400 Bad Request): {"success":false,"error":"ElevenLabs transcription failed (401): {\"detail\":{\"status\":\"payment_required\",\"message\":\"Your subscription has a failed or incomplete payment\"}}","errorCode":"BAD_REQUEST"}"#; | ||
| /// Streaming TTS uses a third wrapper. | ||
| const HOSTED_PAYMENT_STREAM_TTS: &str = | ||
| r#"ElevenLabs streaming TTS failed (401): {"detail":{"status":"payment_required"}}"#; | ||
| /// A BYOK request refused because the user's own subscription is unpaid, in the | ||
| /// ElevenLabs client's format (`[voice-tts] elevenlabs error {status}: {body}`). | ||
| const BYOK_PAYMENT_TTS: &str = r#"[voice-tts] elevenlabs error 401 Unauthorized: {"detail":{"status":"payment_required","message":"Your subscription has a failed or incomplete payment"}}"#; | ||
| /// TAURI-RUST-1635: a BYOK user pasted the key ID. | ||
| const KEY_ID_STT: &str = r#"[voice-stt] elevenlabs error 400 Bad Request: {"detail":{"status":"invalid_api_key","message":"API key ID used as API key - only valid API keys can be used. API keys start with 'sk_'"}}"#; | ||
|
|
||
| #[test] | ||
| fn byok_payment_required_maps_and_demotes() { | ||
| assert_eq!( | ||
| voice_provider_user_message(BYOK_PAYMENT_TTS), | ||
| Some(VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE) | ||
| ); | ||
| assert_eq!( | ||
| expected_error_kind(BYOK_PAYMENT_TTS), | ||
| Some(ExpectedErrorKind::ProviderUserState) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn key_id_maps_and_demotes() { | ||
| assert_eq!( | ||
| voice_provider_user_message(KEY_ID_STT), | ||
| Some(VOICE_PROVIDER_KEY_ID_USER_MESSAGE) | ||
| ); | ||
| assert_eq!( | ||
| expected_error_kind(KEY_ID_STT), | ||
| Some(ExpectedErrorKind::ProviderUserState) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn hosted_payment_required_is_not_user_state() { | ||
| // The backend's own account: no "update billing" message, and it keeps | ||
| // reaching Sentry so ops sees it. | ||
| for raw in [ | ||
| HOSTED_PAYMENT_TTS, | ||
| HOSTED_PAYMENT_STT, | ||
| HOSTED_PAYMENT_STREAM_TTS, | ||
| ] { | ||
| assert_eq!(voice_provider_user_message(raw), None, "{raw}"); | ||
| assert!(!is_voice_provider_user_state_message(raw), "{raw}"); | ||
| assert_ne!( | ||
| expected_error_kind(raw), | ||
| Some(ExpectedErrorKind::ProviderUserState), | ||
| "{raw}" | ||
| ); | ||
| assert_eq!(map_voice_provider_error(raw.to_string()), raw); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn mapped_user_messages_stay_demoted_at_rpc_boundary() { | ||
| for m in [ | ||
| VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE, | ||
| VOICE_PROVIDER_KEY_ID_USER_MESSAGE, | ||
| ] { | ||
| assert_eq!( | ||
| expected_error_kind(m), | ||
| Some(ExpectedErrorKind::ProviderUserState), | ||
| "{m}" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn map_voice_provider_error_passes_unknown_through() { | ||
| assert_eq!( | ||
| map_voice_provider_error(BYOK_PAYMENT_TTS.to_string()), | ||
| VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE | ||
| ); | ||
| let other = "[voice-tts] elevenlabs 500: upstream exploded"; | ||
| assert_eq!(map_voice_provider_error(other.to_string()), other); | ||
| assert_eq!(expected_error_kind(other), None); | ||
| } | ||
|
|
||
| #[test] | ||
| fn unrelated_payment_required_is_not_claimed() { | ||
| assert_eq!(voice_provider_user_message("stripe payment_required"), None); | ||
| } | ||
|
|
||
| #[test] | ||
| fn other_byok_providers_are_not_claimed() { | ||
| // OpenAI-compatible and Deepgram styles share the external clients but use | ||
| // their own prefixes; the ElevenLabs guidance must not reach them. | ||
| for raw in [ | ||
| r#"[voice-stt] external STT error 400 Bad Request: API key ID used as API key"#, | ||
| r#"[voice-tts] external TTS error 402 Payment Required: payment_required"#, | ||
| r#"[voice-stt] deepgram error 401 Unauthorized: API key ID used as API key"#, | ||
| ] { | ||
| assert_eq!(voice_provider_user_message(raw), None, "{raw}"); | ||
| assert_ne!( | ||
| expected_error_kind(raw), | ||
| Some(ExpectedErrorKind::ProviderUserState), | ||
| "{raw}" | ||
| ); | ||
| assert_eq!(map_voice_provider_error(raw.to_string()), raw); | ||
| } | ||
| } |
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
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
90 changes: 90 additions & 0 deletions
90
crates/openhuman-core/src/core/observability_voice_provider_errors.rs
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| // ── Voice-provider (ElevenLabs) account / key misconfiguration ───────────── | ||
| // | ||
| // Two different owners hide behind the same ElevenLabs wording: | ||
| // | ||
| // - **BYOK** (the external STT/TTS providers, called with the user's own | ||
| // ElevenLabs key): `payment_required` is the user's own subscription and | ||
| // "API key ID used as API key" (TAURI-RUST-1635) is a pasted key ID. Both | ||
| // are user state: mapped to an actionable message and demoted from Sentry. | ||
| // - **Hosted** (a host's own voice backend calling ElevenLabs with its own | ||
| // key): not the user's account. Never matched here (only the BYOK client's | ||
| // error prefixes are); the host owns mapping and reporting those. | ||
|
|
||
| /// User-facing message for an ElevenLabs `payment_required` rejection of the | ||
| /// user's own (BYOK) key. | ||
| pub const VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE: &str = | ||
| "Your ElevenLabs subscription has a payment issue. Update billing to continue."; | ||
|
|
||
| /// User-facing message for an ElevenLabs key-ID-instead-of-key rejection. | ||
| pub const VOICE_PROVIDER_KEY_ID_USER_MESSAGE: &str = | ||
| "That looks like an ElevenLabs key ID, not a key. Keys start with `sk_`. Re-enter the API key."; | ||
|
|
||
| /// The BYOK ElevenLabs client's error prefixes (`tinyinference_voice` | ||
| /// `external_stt` / `external_tts`, ElevenLabs API style). The OpenAI-compatible | ||
| /// and Deepgram styles use different prefixes, so matching on these keeps the | ||
| /// ElevenLabs-specific messages off the other providers. | ||
| const BYOK_ELEVENLABS_ERROR_PREFIXES: &[&str] = &[ | ||
| "[voice-stt] elevenlabs error ", | ||
| "[voice-tts] elevenlabs error ", | ||
| ]; | ||
|
|
||
| /// A BYOK voice-provider condition the user can fix. | ||
| #[derive(Debug, Clone, Copy)] | ||
| enum ByokCondition { | ||
| KeyId, | ||
| PaymentRequired, | ||
| } | ||
|
|
||
| /// Classify `lower` (ASCII-lowercased) as a BYOK ElevenLabs user-config error. | ||
| fn byok_condition(lower: &str) -> Option<ByokCondition> { | ||
| if !BYOK_ELEVENLABS_ERROR_PREFIXES | ||
| .iter() | ||
| .any(|prefix| lower.contains(prefix)) | ||
| { | ||
| return None; | ||
| } | ||
| if lower.contains("api key id used as api key") { | ||
| Some(ByokCondition::KeyId) | ||
| } else if lower.contains("payment_required") || lower.contains("failed or incomplete payment") { | ||
| Some(ByokCondition::PaymentRequired) | ||
| } else { | ||
| None | ||
| } | ||
| } | ||
|
|
||
| /// Map a raw BYOK ElevenLabs error to an actionable user-facing message, or | ||
| /// `None` when the error is not one of the known user-config conditions. | ||
| /// Hosted failures and other providers are never matched: see the module | ||
| /// comment and [`BYOK_ELEVENLABS_ERROR_PREFIXES`]. | ||
| pub fn voice_provider_user_message(message: &str) -> Option<&'static str> { | ||
| match byok_condition(&message.to_ascii_lowercase())? { | ||
| ByokCondition::KeyId => Some(VOICE_PROVIDER_KEY_ID_USER_MESSAGE), | ||
| ByokCondition::PaymentRequired => Some(VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE), | ||
| } | ||
| } | ||
|
|
||
| /// Whether `message` is a raw BYOK voice-provider config error or one of our | ||
| /// own mapped BYOK user messages (the latter so the RPC-boundary re-report of | ||
| /// the already-mapped string is demoted too). | ||
| pub fn is_voice_provider_user_state_message(message: &str) -> bool { | ||
| let lower = message.to_ascii_lowercase(); | ||
| voice_provider_user_message(&lower).is_some() | ||
| || lower.contains(&VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE.to_ascii_lowercase()) | ||
| || lower.contains(&VOICE_PROVIDER_KEY_ID_USER_MESSAGE.to_ascii_lowercase()) | ||
| } | ||
|
|
||
| /// Replace a known BYOK voice-provider error with its user-facing message, | ||
| /// logging only the condition at `warn` (the raw error carries the upstream | ||
| /// response body). Unknown errors pass through unchanged. | ||
| pub fn map_voice_provider_error(raw: String) -> String { | ||
| match voice_provider_user_message(&raw) { | ||
| Some(user) => { | ||
| let condition = byok_condition(&raw.to_ascii_lowercase()); | ||
| tracing::warn!( | ||
| "[voice-provider] ElevenLabs account/key problem mapped to user message: {condition:?}" | ||
| ); | ||
| user.to_string() | ||
| } | ||
| None => raw, | ||
| } | ||
| } | ||
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.