diff --git a/FINDINGS-voiceerr.md b/FINDINGS-voiceerr.md new file mode 100644 index 0000000000..1dc6da176f --- /dev/null +++ b/FINDINGS-voiceerr.md @@ -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 (): ")` (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`. diff --git a/crates/openhuman-core/src/core/observability.rs b/crates/openhuman-core/src/core/observability.rs index 9aac3997e9..b2bba466fc 100644 --- a/crates/openhuman-core/src/core/observability.rs +++ b/crates/openhuman-core/src/core/observability.rs @@ -371,6 +371,7 @@ include!("observability_expected_error_classification.rs"); include!("observability_plan_and_session_errors.rs"); include!("observability_provider_local_errors.rs"); include!("observability_network_provider_errors.rs"); +include!("observability_voice_provider_errors.rs"); include!("observability_reporting.rs"); include!("observability_event_classification.rs"); include!("observability_backend_event_classification.rs"); diff --git a/crates/openhuman-core/src/core/observability_error_classification_voice_provider_tests.rs b/crates/openhuman-core/src/core/observability_error_classification_voice_provider_tests.rs new file mode 100644 index 0000000000..814f15de30 --- /dev/null +++ b/crates/openhuman-core/src/core/observability_error_classification_voice_provider_tests.rs @@ -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); + } +} diff --git a/crates/openhuman-core/src/core/observability_expected_error_classification.rs b/crates/openhuman-core/src/core/observability_expected_error_classification.rs index f7090e25f4..9deb5abcf8 100644 --- a/crates/openhuman-core/src/core/observability_expected_error_classification.rs +++ b/crates/openhuman-core/src/core/observability_expected_error_classification.rs @@ -1,4 +1,3 @@ - pub fn expected_error_kind(message: &str) -> Option { let lower = message.to_ascii_lowercase(); // Explicit host ownership/reportability metadata precedes text heuristics. @@ -62,6 +61,15 @@ pub fn expected_error_kind(message: &str) -> Option { if is_wallet_not_configured_message(&lower) { return Some(ExpectedErrorKind::WalletNotConfigured); } + // TAURI-RUST-1635 and BYOK ElevenLabs payment failures: the user's own key + // is a key ID or their own subscription is unpaid (user config, not a code + // defect). Hosted failures (15BM / 15CT) are never claimed here. + // Checked early: the wrapped body carries `401` / `400` tokens that the + // broader matchers below would otherwise claim with a less specific kind. + // See `observability_voice_provider_errors.rs`. + if is_voice_provider_user_state_message(message) { + return Some(ExpectedErrorKind::ProviderUserState); + } // TAURI-RUST-117K et al. — see `ExpectedErrorKind::ModuleUnavailable`. if is_module_unavailable_message(message) { return Some(ExpectedErrorKind::ModuleUnavailable); diff --git a/crates/openhuman-core/src/core/observability_tests.rs b/crates/openhuman-core/src/core/observability_tests.rs index 5016996e38..a59b32bacc 100644 --- a/crates/openhuman-core/src/core/observability_tests.rs +++ b/crates/openhuman-core/src/core/observability_tests.rs @@ -132,6 +132,8 @@ mod error_classification_provider; mod error_classification_session; #[path = "observability_error_classification_user_state_tests.rs"] mod error_classification_user_state; +#[path = "observability_error_classification_voice_provider_tests.rs"] +mod error_classification_voice_provider; /// Log output of `f` under a plain fmt subscriber. fn captured_logs(f: impl FnOnce()) -> String { diff --git a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs new file mode 100644 index 0000000000..3a49abde6c --- /dev/null +++ b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs @@ -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 { + 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, + } +} diff --git a/crates/openhuman-core/src/voice/factory/stt_providers.rs b/crates/openhuman-core/src/voice/factory/stt_providers.rs index e235bfbec0..81418552c8 100644 --- a/crates/openhuman-core/src/voice/factory/stt_providers.rs +++ b/crates/openhuman-core/src/voice/factory/stt_providers.rs @@ -78,7 +78,8 @@ impl SttProvider for ExternalSttProvider { let text = self .client .transcribe(&audio_bytes, mime, file_name, language) - .await?; + .await + .map_err(crate::core::observability::map_voice_provider_error)?; Ok(Outcome::single_log( SttResult { diff --git a/crates/openhuman-core/src/voice/factory/tts_providers.rs b/crates/openhuman-core/src/voice/factory/tts_providers.rs index 3a817aaab9..9ec3c882f5 100644 --- a/crates/openhuman-core/src/voice/factory/tts_providers.rs +++ b/crates/openhuman-core/src/voice/factory/tts_providers.rs @@ -118,7 +118,11 @@ impl TtsProvider for ExternalTtsProvider { text.len() ); - let (audio_bytes, audio_mime) = self.client.synthesize(text, resolved_voice).await?; + let (audio_bytes, audio_mime) = self + .client + .synthesize(text, resolved_voice) + .await + .map_err(crate::core::observability::map_voice_provider_error)?; use base64::Engine; let audio_base64 = base64::engine::general_purpose::STANDARD.encode(&audio_bytes); diff --git a/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs b/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs index febe3ae1d4..ce0176538b 100644 --- a/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs +++ b/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs @@ -135,8 +135,41 @@ pub fn body_flags_malformed(err: &str) -> bool { after_colon.starts_with("true") } +/// Whether `err` is exactly the neutral hosted-voice copy, bare or behind an +/// RPC method name (`voice.reply_synthesize: `). Matching the sentence +/// anywhere would also exempt another provider's error that happens to quote +/// it, which `authed_json` never reported. +fn is_hosted_voice_unavailable_copy(err: &str) -> bool { + let message = crate::voice::reply_speech::HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE; + let err = err.trim(); + if err == message { + return true; + } + err.strip_suffix(message) + .and_then(|rest| rest.strip_suffix(": ")) + .is_some_and(|method| { + !method.is_empty() + && method + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') + }) +} + /// Classify an error for the core's generic observability and chat surfaces. pub(crate) fn classify(provider: Option<&str>, err: &str) -> BackendErrorClassification { + // The neutral hosted-voice copy replaces a failure `authed_json` already + // reported or demoted: backend-owned, so the RPC boundary does not report + // it again. No token or kind, so chat display is unaffected. + if is_hosted_voice_unavailable_copy(err) { + return BackendErrorClassification { + provider_owned: false, + backend_owned: true, + reportable: false, + token: None, + kind: None, + malformed_bad_request: false, + }; + } let provider_owned = provider.map_or_else( || is_managed_backend_envelope(err), |provider| matches!(provider, PROVIDER_LABEL | "OpenHuman"), diff --git a/crates/openhuman-tinyhumans/src/inference/backend_error_code_tests.rs b/crates/openhuman-tinyhumans/src/inference/backend_error_code_tests.rs index 0ee49d80b5..7cd3b5dc83 100644 --- a/crates/openhuman-tinyhumans/src/inference/backend_error_code_tests.rs +++ b/crates/openhuman-tinyhumans/src/inference/backend_error_code_tests.rs @@ -287,3 +287,30 @@ fn classification_keeps_provider_ownership_separate_from_raw_token_and_reportabi assert!(malformed.reportable); assert!(malformed.malformed_bad_request); } + +#[test] +fn hosted_voice_unavailable_copy_is_backend_owned_and_not_reported_again() { + // `authed_json` already reported (or demoted as transient) the failure the + // neutral copy replaces; the RPC boundary must not report it a second time. + let message = crate::voice::reply_speech::HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE; + for err in [ + message.to_string(), + format!("voice.reply_synthesize: {message}"), + ] { + let classification = classify(None, &err); + assert!( + classification.backend_owned && !classification.reportable, + "{err}" + ); + assert_eq!(classification.kind, None, "{err}"); + } + // Another provider's error that merely quotes the sentence is not exempt. + for err in [ + format!("custom provider error 500: {message}"), + format!("{message} (upstream said so)"), + ] { + let classification = classify(None, &err); + assert!(!classification.backend_owned, "{err}"); + assert!(classification.reportable, "{err}"); + } +} diff --git a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs index 69768f8fd0..b377169c21 100644 --- a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs +++ b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs @@ -30,6 +30,84 @@ const LOG_PREFIX: &str = "[voice_reply]"; /// The route this module posts to. const SPEECH_PATH: &str = "/openai/v1/audio/speech"; +/// User-facing message for a hosted voice request the backend's own upstream +/// provider refused. Nothing the user can change, so no instruction. The +/// failure behind it was already reported (or demoted as transient) by +/// `authed_json`, so the backend-error classifier marks this message +/// backend-owned and not reportable (no second event at the RPC boundary). +pub const HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE: &str = + "The voice service is temporarily unavailable. Please try again later."; + +/// The backend's wrappings of an upstream ElevenLabs failure +/// (`elevenlabsService` in the backend: TTS, streaming TTS, transcription). +/// The backend always calls ElevenLabs with its own key, so a refusal there +/// (TAURI-RUST-15BM / -15CT) is TinyHumans' account, not the user's. +const HOSTED_ELEVENLABS_FAILURE_MARKERS: &[&str] = &[ + "elevenlabs request failed (", + "elevenlabs streaming tts failed (", + "elevenlabs transcription failed (", +]; + +/// ElevenLabs `detail.status` codes meaning the backend's own account is out +/// of money or quota, whatever HTTP status they arrive under. +const HOSTED_BILLING_CODES: &[&str] = + &["payment_required", "insufficient_credits", "quota_exceeded"]; + +/// Allowlisted category of a hosted upstream failure on the backend's own +/// ElevenLabs account or service: logged in place of the raw body. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HostedUpstreamFailure { + Billing, + Account, + RateLimited, + Service, +} + +/// Classify a flattened hosted error as a failure of the backend's own +/// ElevenLabs account or service, returning the category and the upstream +/// status. Request errors the caller can fix (`voice_not_found`, text too +/// long, other 4xx) return `None` so their message reaches the user. +pub(crate) fn hosted_upstream_failure(raw: &str) -> Option<(HostedUpstreamFailure, u16)> { + let lower = raw.to_ascii_lowercase(); + let (start, marker) = HOSTED_ELEVENLABS_FAILURE_MARKERS + .iter() + .find_map(|marker| lower.find(marker).map(|idx| (idx, *marker)))?; + let digits: String = lower[start + marker.len()..] + .chars() + .take_while(char::is_ascii_digit) + .collect(); + let status = digits.parse::().ok()?; + let category = if status == 402 || HOSTED_BILLING_CODES.iter().any(|code| lower.contains(code)) + { + HostedUpstreamFailure::Billing + } else if matches!(status, 401 | 403 | 412) { + HostedUpstreamFailure::Account + } else if status == 429 { + HostedUpstreamFailure::RateLimited + } else if status >= 500 { + HostedUpstreamFailure::Service + } else { + return None; + }; + Some((category, status)) +} + +/// Replace a hosted voice error caused by the backend's own upstream account +/// or service with [`HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE`], logging only the +/// allowlisted category and upstream status. Request errors and anything else +/// pass through unchanged. +pub(crate) fn map_hosted_voice_error(raw: String) -> String { + match hosted_upstream_failure(&raw) { + Some((category, status)) => { + warn!( + "{LOG_PREFIX} hosted upstream provider failure mapped to user message: category={category:?} upstream_status={status}" + ); + HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE.to_string() + } + None => raw, + } +} + /// Env var that activates the [`test_seam`] short-circuit at runtime. When /// set to `1` / `true`, [`synthesize_reply`] records the requested text /// into [`test_seam::OBSERVED_CALLS`] and returns a stub @@ -192,7 +270,11 @@ pub async fn synthesize_reply( Some(Value::Object(body)), ) .await - .map_err(flatten_authed_error)?; + .map_err(flatten_authed_error) + // The backend's own ElevenLabs account refused (TAURI-RUST-15BM): + // `authed_json` already reported the raw failure, so the user gets a + // neutral "unavailable" message instead of a backend error line. + .map_err(map_hosted_voice_error)?; let result = normalize_response(&raw); debug!( diff --git a/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs b/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs index e963fd8e8c..fbe59931e6 100644 --- a/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs +++ b/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs @@ -56,3 +56,63 @@ fn tts_non_auth_error_is_not_demoted_to_session_expiry() { "non-auth TTS error must NOT classify as session expiry: {flat}" ); } + +#[test] +fn hosted_upstream_failure_maps_to_neutral_message() { + // TAURI-RUST-15BM: the hosted TTS route wrapping a refusal of the + // backend's own ElevenLabs account. + let hosted = r#"POST /openai/v1/audio/speech failed (400 Bad Request): {"success":false,"error":"ElevenLabs request failed (401): {\"detail\":{\"status\":\"payment_required\"}}","errorCode":"BAD_REQUEST"}"#; + assert_eq!( + super::map_hosted_voice_error(hosted.to_string()), + super::HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE + ); + let other = "POST /openai/v1/audio/speech failed (400 Bad Request): {\"error\":\"x\"}"; + assert_eq!(super::map_hosted_voice_error(other.to_string()), other); +} + +#[test] +fn hosted_upstream_failure_categories() { + use super::{hosted_upstream_failure, HostedUpstreamFailure as F}; + let wrap = |inner: &str| { + format!(r#"POST /openai/v1/audio/speech failed (400 Bad Request): {{"error":"{inner}"}}"#) + }; + for (inner, expected) in [ + ( + r#"ElevenLabs request failed (401): {\"detail\":{\"status\":\"payment_required\"}}"#, + (F::Billing, 401), + ), + ("ElevenLabs request failed (402): {}", (F::Billing, 402)), + ( + "ElevenLabs transcription failed (403): {}", + (F::Account, 403), + ), + ( + "ElevenLabs streaming TTS failed (429): {}", + (F::RateLimited, 429), + ), + ("ElevenLabs request failed (503): {}", (F::Service, 503)), + ] { + assert_eq!( + hosted_upstream_failure(&wrap(inner)), + Some(expected), + "{inner}" + ); + } +} + +#[test] +fn hosted_request_errors_keep_their_message() { + // The caller can fix these (bad voice id, text too long): retrying later + // cannot, so the original message must reach the user. + for inner in [ + r#"ElevenLabs request failed (404): {\"detail\":{\"status\":\"voice_not_found\",\"message\":\"A voice with that id was not found.\"}}"#, + r#"ElevenLabs request failed (400): {\"detail\":{\"status\":\"max_character_limit_exceeded\"}}"#, + "ElevenLabs request failed (422): {}", + ] { + let raw = format!( + r#"POST /openai/v1/audio/speech failed (400 Bad Request): {{"error":"{inner}"}}"# + ); + assert_eq!(super::hosted_upstream_failure(&raw), None, "{inner}"); + assert_eq!(super::map_hosted_voice_error(raw.clone()), raw); + } +}