From 6a5a8dff2b1f09aa7a74c070ae71a6595c4e979d Mon Sep 17 00:00:00 2001 From: sanil Date: Fri, 30 Oct 2026 13:16:19 +0000 Subject: [PATCH 1/9] fix(voice): map ElevenLabs payment/key-ID errors to user messages and demote from Sentry Map ElevenLabs payment_required and key-ID-as-key rejections to actionable user-facing messages in the external STT/TTS providers and hosted reply_speech, classify them as ProviderUserState, and stop reporting matching backend 400s from authed_json to Sentry (TAURI-RUST-15BM, 15CT, 1635; 112J documented). Co-Authored-By: Claude Sonnet 5.5 --- FINDINGS-voiceerr.md | 52 +++++++++++++++++ .../openhuman-core/src/core/observability.rs | 1 + ...ror_classification_voice_provider_tests.rs | 58 +++++++++++++++++++ ...rvability_expected_error_classification.rs | 9 ++- .../src/core/observability_tests.rs | 2 + .../observability_voice_provider_errors.rs | 58 +++++++++++++++++++ .../src/voice/factory/stt_providers.rs | 3 +- .../src/voice/factory/tts_providers.rs | 6 +- crates/openhuman-embed/src/host_internals.rs | 3 +- .../src/client/classify.rs | 23 +++++++- .../src/voice/reply_speech.rs | 5 +- 11 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 FINDINGS-voiceerr.md create mode 100644 crates/openhuman-core/src/core/observability_error_classification_voice_provider_tests.rs create mode 100644 crates/openhuman-core/src/core/observability_voice_provider_errors.rs diff --git a/FINDINGS-voiceerr.md b/FINDINGS-voiceerr.md new file mode 100644 index 0000000000..3c080003ea --- /dev/null +++ b/FINDINGS-voiceerr.md @@ -0,0 +1,52 @@ +# FINDINGS: voice-provider error handling (Workstream C) + +Branch: `fix/voice-provider-error-handling` (GitHub #8502, #8503). Not pushed. + +## 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*. That is the TAURI-RUST-15BM/-112J flood. + The returned error string still carries up to 512 bytes of the body (RPC error / daily logs). +- Existing convention: `core/observability*.rs` `expected_error_kind` -> `ExpectedErrorKind::ProviderUserState` + (logged at info, not sent to Sentry); callers use `report_error_or_expected`. + +## What I changed +- New `crates/openhuman-core/src/core/observability_voice_provider_errors.rs` (included into `observability.rs`): + - `voice_provider_user_message()` maps `elevenlabs` + `payment_required`/"failed or incomplete payment" to + "Your ElevenLabs subscription has a payment issue. Update billing to continue." and "API key ID used as API key" + to "That looks like an ElevenLabs key ID, not a key. Keys start with `sk_`. Re-enter the API key." + - `is_voice_provider_user_state_message()` matches the raw upstream text AND the mapped messages, so the re-report at + the RPC boundary stays demoted. + - `map_voice_provider_error()` swaps in the user message (raw error kept in a `warn` log); unknown errors pass through. +- `expected_error_kind` returns `ProviderUserState` for these (early arm, so broader 400/401 matchers cannot claim them). +- `openhuman-tinyhumans/src/client/classify.rs`: a 400 whose body matches is logged at `warn` and NOT `report_error`ed + (same pattern as the existing budget-exhausted 400 branch). +- User-facing mapping applied in `ExternalSttProvider`, `ExternalTtsProvider` (core factory) and hosted + `reply_speech.rs`. `openhuman-embed/src/host_internals.rs` re-exports the two helpers. +- Tests: `observability_error_classification_voice_provider_tests.rs` (payment, key-ID, mapped-message re-report, + pass-through of unknown bodies, no over-match). + +## TAURI-RUST-112J classification +Classified as **config / upstream (probable), not confirmed**; no code defect identified. Reasoning: same endpoint +(`POST /openai/v1/audio/speech`), same status (400), and a backend error envelope (`error,errorCode,success`) that +wraps an upstream provider failure, the same shape class as 15BM. But the telemetry holds only the key names, so +I cannot prove the wrapped text is `payment_required`. I deliberately did NOT demote unknown 400s on that path: if the +body is not one of the two known ElevenLabs conditions it still reports. If the 53 users' bodies are the payment +case, they are now demoted. To close it out: pull the body from a user's daily log (the error string carries the +first 512 bytes) and add its marker to `voice_provider_user_message`, or if it is a different upstream +condition, treat it as a server-side/config issue in the TinyHumans backend (different repo). + +## Verification (limited, please read) +- `cargo check -p openhuman` fails on a **clean** `upstream/main` too (401 errors, identical count before/after my + change): vendored submodules are pinned to commits older than the API the host code uses + (e.g. `tinychannels_bus::module_progressive::ChannelOutputEvent`, `tinyflows_bus::schedule`, + `tinyagents_harness::progress` missing). This blocks `cargo check/test` for `openhuman`, `openhuman-tinyhumans` and + `openhuman-embed`. I did not touch vendor/. Needs submodule pins bumped upstream. +- Instead I compiled and tested the new pure module standalone (stubbed `tracing`): mapping, mapped-message + re-match, and non-match cases pass. The wiring edits (classify.rs, reply_speech.rs, factory providers, embed + re-export, observability include and arm) are small, were formatted with rustfmt, and checked by reading, but + were NOT compiled in-tree; the new in-tree test file has not been run. +- Note: a sccache `EAGAIN` failure 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..8a69a5867b --- /dev/null +++ b/crates/openhuman-core/src/core/observability_error_classification_voice_provider_tests.rs @@ -0,0 +1,58 @@ +use super::*; + +const PAYMENT_TTS: &str = r#"POST /openai/v1/audio/speech failed (400 Bad Request): ElevenLabs request failed (401): payment_required — "Your subscription has a failed or incomplete payment""#; +const KEY_ID_STT: &str = r#"[voice-stt] elevenlabs 400 invalid_api_key: "API key ID used as API key - only valid API keys can be used. API keys start with 'sk_'""#; + +#[test] +fn payment_required_maps_and_demotes() { + assert_eq!( + voice_provider_user_message(PAYMENT_TTS), + Some(VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE) + ); + assert_eq!( + expected_error_kind(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 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(PAYMENT_TTS.to_string()), + VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE + ); + let other = "POST /openai/v1/audio/speech failed (400 Bad Request): {\"error\":\"x\"}"; + 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); +} 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..eb37e298c9 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,14 @@ pub fn expected_error_kind(message: &str) -> Option { if is_wallet_not_configured_message(&lower) { return Some(ExpectedErrorKind::WalletNotConfigured); } + // TAURI-RUST-15BM / -15CT / -1635 — ElevenLabs account payment failure or a + // key ID pasted as the API key (upstream/user config, not a code defect). + // 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..2b7e82e826 --- /dev/null +++ b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs @@ -0,0 +1,58 @@ +// ── Voice-provider (ElevenLabs) account / key misconfiguration ───────────── +// +// TAURI-RUST-15BM / -15CT (ElevenLabs `401 payment_required`) and +// TAURI-RUST-1635 (ElevenLabs `invalid_api_key`, "API key ID used as API key"). +// These are third-party account or user-entered-key states, not code defects: +// the upstream account has a failed payment, or the user pasted an API key +// *ID* instead of the key. OpenHuman has no lever to fix either, so they are +// mapped to an actionable user-facing message and demoted out of Sentry. + +/// User-facing message for an ElevenLabs `payment_required` rejection. +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."; + +/// Map a raw voice-provider error to an actionable user-facing message, or +/// `None` when the error is not one of the known upstream-config conditions. +/// +/// Matching is anchored on the upstream wording only; response bodies are not +/// assumed to have any other structure. +pub fn voice_provider_user_message(message: &str) -> Option<&'static str> { + let lower = message.to_ascii_lowercase(); + if lower.contains("api key id used as api key") { + return Some(VOICE_PROVIDER_KEY_ID_USER_MESSAGE); + } + if lower.contains("elevenlabs") + && (lower.contains("payment_required") || lower.contains("failed or incomplete payment")) + { + return Some(VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE); + } + None +} + +/// Whether `message` is a raw upstream voice-provider config error or one of +/// our own mapped 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 upstream voice-provider error with its user-facing message, +/// logging the raw error at `warn`. Unknown errors pass through unchanged. +pub fn map_voice_provider_error(raw: String) -> String { + match voice_provider_user_message(&raw) { + Some(user) => { + tracing::warn!( + "[voice-provider] upstream account/key problem mapped to user message: {raw}" + ); + 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-embed/src/host_internals.rs b/crates/openhuman-embed/src/host_internals.rs index 8b0c0a1c7d..d7f9022e07 100644 --- a/crates/openhuman-embed/src/host_internals.rs +++ b/crates/openhuman-embed/src/host_internals.rs @@ -39,7 +39,8 @@ pub mod core { pub use openhuman_core::core::observability::{ contains_transient_transport_phrase, expected_error_kind, is_api_key_rejected_message, is_session_expired_message, is_suppressed_usage_probe_backoff, - is_transient_http_status_code, is_transient_message_failure, report_error, + is_transient_http_status_code, is_transient_message_failure, + is_voice_provider_user_state_message, map_voice_provider_error, report_error, report_error_or_expected, report_warning_message, ExpectedErrorKind, API_KEY_REJECTED_PREFIX, BACKEND_UNAVAILABLE_PREFIX, REPORT_ERROR_TRACING_TARGET, USAGE_PROBE_BACKOFF_PREFIX, diff --git a/crates/openhuman-tinyhumans/src/client/classify.rs b/crates/openhuman-tinyhumans/src/client/classify.rs index b38b3c0ff5..bd69605c64 100644 --- a/crates/openhuman-tinyhumans/src/client/classify.rs +++ b/crates/openhuman-tinyhumans/src/client/classify.rs @@ -5,7 +5,8 @@ use crate::transport::BackendTransportError; use anyhow::{Context, Result}; use openhuman_embed::__host::core::observability::{ - contains_transient_transport_phrase, is_transient_http_status_code, report_error, + contains_transient_transport_phrase, is_transient_http_status_code, + is_voice_provider_user_state_message, report_error, }; use openhuman_embed::__host::util::truncate_at_byte_boundary; use reqwest::Method; @@ -209,7 +210,25 @@ impl BackendClient { let is_transient_infra = is_transient_http_status_code(status_code); let is_budget_exhausted = status_code == 400 && tinyinference_providers::is_budget_exhausted_message(&text); - if is_budget_exhausted { + // Upstream voice-provider account/key problems (ElevenLabs + // `payment_required`, key ID pasted as key) wrapped in a backend 400 + // are user/upstream config, not code defects (TAURI-RUST-15BM/-15CT). + let is_voice_provider_config = + status_code == 400 && is_voice_provider_user_state_message(&text); + if is_voice_provider_config { + tracing::warn!( + domain = "backend_api", + operation = "authed_json", + method = method.as_str(), + path = url.path(), + status = status_code, + failure = "non_2xx", + kind = "voice_provider_config", + "[backend_api] voice-provider config 400 on {} {}: not reporting to Sentry", + method.as_str(), + url.path(), + ); + } else if is_budget_exhausted { tracing::info!( method = method.as_str(), path = url.path(), diff --git a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs index 69768f8fd0..d5d1db02fa 100644 --- a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs +++ b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs @@ -192,7 +192,10 @@ pub async fn synthesize_reply( Some(Value::Object(body)), ) .await - .map_err(flatten_authed_error)?; + .map_err(flatten_authed_error) + // Known upstream ElevenLabs account/key problems become an actionable + // user-facing message (TAURI-RUST-15BM); other errors pass through. + .map_err(openhuman_embed::__host::core::observability::map_voice_provider_error)?; let result = normalize_response(&raw); debug!( From a45e6b8bbb8de81c5386857aac9aeaba1030d726 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:57:36 +0000 Subject: [PATCH 2/9] fix(voice): keep hosted ElevenLabs billing failures reported, neutral user message The hosted /openai/v1/audio/* routes call ElevenLabs with the backend's own key, so a wrapped `payment_required` (TAURI-RUST-15BM / -15CT) is TinyHumans' account, not the user's. Stop telling those users to update their billing and stop demoting the event: revert the authed_json demotion, exclude the backend's `ElevenLabs request failed (` wrapping from the BYOK user-state matcher, and map hosted TTS failures to a neutral "temporarily unavailable" message. BYOK handling (payment_required on the user's own key, key ID pasted as key, TAURI-RUST-1635) is unchanged. Co-Authored-By: Claude Opus 5.5 --- FINDINGS-voiceerr.md | 98 ++++++++++++------- ...ror_classification_voice_provider_tests.rs | 50 ++++++++-- .../observability_voice_provider_errors.rs | 63 +++++++++--- crates/openhuman-embed/src/host_internals.rs | 5 +- .../src/client/classify.rs | 23 +---- .../src/voice/reply_speech.rs | 7 +- 6 files changed, 163 insertions(+), 83 deletions(-) diff --git a/FINDINGS-voiceerr.md b/FINDINGS-voiceerr.md index 3c080003ea..5f181f9001 100644 --- a/FINDINGS-voiceerr.md +++ b/FINDINGS-voiceerr.md @@ -2,51 +2,75 @@ Branch: `fix/voice-provider-error-handling` (GitHub #8502, #8503). Not pushed. +## 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 (`reply_speech.rs`) now uses `map_hosted_voice_error`, which swaps the raw backend line for + "The voice service is temporarily unavailable. Please try again later." (the raw error was already + reported by `authed_json` and is logged at `warn`). +- 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 should also surface upstream 401/402 as a 5xx (or a distinct `errorCode`) and alert on it +(backend repo, not changed here). + ## 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*. That is the TAURI-RUST-15BM/-112J flood. - The returned error string still carries up to 512 bytes of the body (RPC error / daily logs). + 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); callers use `report_error_or_expected`. - -## What I changed -- New `crates/openhuman-core/src/core/observability_voice_provider_errors.rs` (included into `observability.rs`): - - `voice_provider_user_message()` maps `elevenlabs` + `payment_required`/"failed or incomplete payment" to - "Your ElevenLabs subscription has a payment issue. Update billing to continue." and "API key ID used as API key" - to "That looks like an ElevenLabs key ID, not a key. Keys start with `sk_`. Re-enter the API key." - - `is_voice_provider_user_state_message()` matches the raw upstream text AND the mapped messages, so the re-report at - the RPC boundary stays demoted. - - `map_voice_provider_error()` swaps in the user message (raw error kept in a `warn` log); unknown errors pass through. -- `expected_error_kind` returns `ProviderUserState` for these (early arm, so broader 400/401 matchers cannot claim them). -- `openhuman-tinyhumans/src/client/classify.rs`: a 400 whose body matches is logged at `warn` and NOT `report_error`ed - (same pattern as the existing budget-exhausted 400 branch). -- User-facing mapping applied in `ExternalSttProvider`, `ExternalTtsProvider` (core factory) and hosted - `reply_speech.rs`. `openhuman-embed/src/host_internals.rs` re-exports the two helpers. -- Tests: `observability_error_classification_voice_provider_tests.rs` (payment, key-ID, mapped-message re-report, - pass-through of unknown bodies, no over-match). + (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) and `map_hosted_voice_error()` (hosted, neutral message). +- `expected_error_kind` returns `ProviderUserState` for the BYOK cases only. +- Mapping applied in `ExternalSttProvider`, `ExternalTtsProvider` (BYOK) and `reply_speech.rs` (hosted, neutral). + `openhuman-embed/src/host_internals.rs` re-exports `map_hosted_voice_error`. +- 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 -Classified as **config / upstream (probable), not confirmed**; no code defect identified. Reasoning: same endpoint -(`POST /openai/v1/audio/speech`), same status (400), and a backend error envelope (`error,errorCode,success`) that -wraps an upstream provider failure, the same shape class as 15BM. But the telemetry holds only the key names, so -I cannot prove the wrapped text is `payment_required`. I deliberately did NOT demote unknown 400s on that path: if the -body is not one of the two known ElevenLabs conditions it still reports. If the 53 users' bodies are the payment -case, they are now demoted. To close it out: pull the body from a user's daily log (the error string carries the -first 512 bytes) and add its marker to `voice_provider_user_message`, or if it is a different upstream -condition, treat it as a server-side/config issue in the TinyHumans backend (different repo). +**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 (401 errors, identical count before/after my - change): vendored submodules are pinned to commits older than the API the host code uses - (e.g. `tinychannels_bus::module_progressive::ChannelOutputEvent`, `tinyflows_bus::schedule`, - `tinyagents_harness::progress` missing). This blocks `cargo check/test` for `openhuman`, `openhuman-tinyhumans` and - `openhuman-embed`. I did not touch vendor/. Needs submodule pins bumped upstream. -- Instead I compiled and tested the new pure module standalone (stubbed `tracing`): mapping, mapped-message - re-match, and non-match cases pass. The wiring edits (classify.rs, reply_speech.rs, factory providers, embed - re-export, observability include and arm) are small, were formatted with rustfmt, and checked by reading, but - were NOT compiled in-tree; the new in-tree test file has not been run. -- Note: a sccache `EAGAIN` failure occurs with default parallelism on this box; use `cargo check -j 12`. +- `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_error_classification_voice_provider_tests.rs b/crates/openhuman-core/src/core/observability_error_classification_voice_provider_tests.rs index 8a69a5867b..abfcf64f9e 100644 --- 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 @@ -1,16 +1,23 @@ use super::*; -const PAYMENT_TTS: &str = r#"POST /openai/v1/audio/speech failed (400 Bad Request): ElevenLabs request failed (401): payment_required — "Your subscription has a failed or incomplete payment""#; +/// 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. +const HOSTED_PAYMENT_STT: &str = r#"transcription request failed (400 Bad Request): {"success":false,"error":"ElevenLabs request failed (401): payment_required — \"Your subscription has a failed or incomplete payment\"","errorCode":"BAD_REQUEST"}"#; +/// A BYOK request refused because the user's own subscription is unpaid. +const BYOK_PAYMENT_TTS: &str = r#"[voice-tts] elevenlabs 401 payment_required: "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 400 invalid_api_key: "API key ID used as API key - only valid API keys can be used. API keys start with 'sk_'""#; #[test] -fn payment_required_maps_and_demotes() { +fn byok_payment_required_maps_and_demotes() { assert_eq!( - voice_provider_user_message(PAYMENT_TTS), + voice_provider_user_message(BYOK_PAYMENT_TTS), Some(VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE) ); assert_eq!( - expected_error_kind(PAYMENT_TTS), + expected_error_kind(BYOK_PAYMENT_TTS), Some(ExpectedErrorKind::ProviderUserState) ); } @@ -27,6 +34,37 @@ fn key_id_maps_and_demotes() { ); } +#[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] { + 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 hosted_upstream_failure_maps_to_neutral_message() { + assert_eq!( + map_hosted_voice_error(HOSTED_PAYMENT_TTS.to_string()), + HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE + ); + let other = "POST /openai/v1/audio/speech failed (400 Bad Request): {\"error\":\"x\"}"; + assert_eq!(map_hosted_voice_error(other.to_string()), other); + // The neutral message is not demoted. + assert_eq!( + expected_error_kind(HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE), + None + ); +} + #[test] fn mapped_user_messages_stay_demoted_at_rpc_boundary() { for m in [ @@ -44,10 +82,10 @@ fn mapped_user_messages_stay_demoted_at_rpc_boundary() { #[test] fn map_voice_provider_error_passes_unknown_through() { assert_eq!( - map_voice_provider_error(PAYMENT_TTS.to_string()), + map_voice_provider_error(BYOK_PAYMENT_TTS.to_string()), VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE ); - let other = "POST /openai/v1/audio/speech failed (400 Bad Request): {\"error\":\"x\"}"; + 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); } diff --git a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs index 2b7e82e826..e3c34cccc1 100644 --- a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs +++ b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs @@ -1,13 +1,20 @@ // ── Voice-provider (ElevenLabs) account / key misconfiguration ───────────── // -// TAURI-RUST-15BM / -15CT (ElevenLabs `401 payment_required`) and -// TAURI-RUST-1635 (ElevenLabs `invalid_api_key`, "API key ID used as API key"). -// These are third-party account or user-entered-key states, not code defects: -// the upstream account has a failed payment, or the user pasted an API key -// *ID* instead of the key. OpenHuman has no lever to fix either, so they are -// mapped to an actionable user-facing message and demoted out of Sentry. - -/// User-facing message for an ElevenLabs `payment_required` rejection. +// 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** (`/openai/v1/audio/*` on the TinyHumans backend, which always +// calls ElevenLabs with the server's own key and wraps any upstream failure +// as `ElevenLabs request failed (): `): a `payment_required` +// there (TAURI-RUST-15BM / -15CT) is TinyHumans' own account, which the user +// cannot fix. It stays a reported error, and the user is only told the +// service is unavailable. + +/// 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."; @@ -15,13 +22,29 @@ pub const VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE: &str = 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."; -/// Map a raw voice-provider error to an actionable user-facing message, or -/// `None` when the error is not one of the known upstream-config conditions. +/// User-facing message for a hosted voice request the backend's own upstream +/// provider refused. Nothing the user can change, so no instruction. +pub const HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE: &str = + "The voice service is temporarily unavailable. Please try again later."; + +/// Whether `lower` (ASCII-lowercased) is the TinyHumans backend's wrapping of +/// an upstream ElevenLabs failure (`elevenlabsService` in the backend), i.e. a +/// hosted request on the server's own ElevenLabs account. +fn is_hosted_elevenlabs_failure(lower: &str) -> bool { + lower.contains("elevenlabs request failed (") +} + +/// Map a raw BYOK voice-provider error to an actionable user-facing message, +/// or `None` when the error is not one of the known user-config conditions. +/// Hosted failures are never matched: see the module comment. /// /// Matching is anchored on the upstream wording only; response bodies are not /// assumed to have any other structure. pub fn voice_provider_user_message(message: &str) -> Option<&'static str> { let lower = message.to_ascii_lowercase(); + if is_hosted_elevenlabs_failure(&lower) { + return None; + } if lower.contains("api key id used as api key") { return Some(VOICE_PROVIDER_KEY_ID_USER_MESSAGE); } @@ -33,8 +56,8 @@ pub fn voice_provider_user_message(message: &str) -> Option<&'static str> { None } -/// Whether `message` is a raw upstream voice-provider config error or one of -/// our own mapped user messages (the latter so the RPC-boundary re-report of +/// 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(); @@ -43,7 +66,7 @@ pub fn is_voice_provider_user_state_message(message: &str) -> bool { || lower.contains(&VOICE_PROVIDER_KEY_ID_USER_MESSAGE.to_ascii_lowercase()) } -/// Replace a known upstream voice-provider error with its user-facing message, +/// Replace a known BYOK voice-provider error with its user-facing message, /// logging the raw error at `warn`. Unknown errors pass through unchanged. pub fn map_voice_provider_error(raw: String) -> String { match voice_provider_user_message(&raw) { @@ -56,3 +79,17 @@ pub fn map_voice_provider_error(raw: String) -> String { None => raw, } } + +/// Replace a hosted voice error caused by the backend's upstream provider with +/// [`HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE`]. Only for call sites whose raw +/// failure has already been reported (`authed_json` reports every non-2xx it +/// does not classify); the raw error is logged at `warn` here. Other errors +/// pass through unchanged. +pub fn map_hosted_voice_error(raw: String) -> String { + if is_hosted_elevenlabs_failure(&raw.to_ascii_lowercase()) { + tracing::warn!("[voice-provider] hosted upstream provider failure: {raw}"); + HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE.to_string() + } else { + raw + } +} diff --git a/crates/openhuman-embed/src/host_internals.rs b/crates/openhuman-embed/src/host_internals.rs index d7f9022e07..3eca869b68 100644 --- a/crates/openhuman-embed/src/host_internals.rs +++ b/crates/openhuman-embed/src/host_internals.rs @@ -39,9 +39,8 @@ pub mod core { pub use openhuman_core::core::observability::{ contains_transient_transport_phrase, expected_error_kind, is_api_key_rejected_message, is_session_expired_message, is_suppressed_usage_probe_backoff, - is_transient_http_status_code, is_transient_message_failure, - is_voice_provider_user_state_message, map_voice_provider_error, report_error, - report_error_or_expected, report_warning_message, ExpectedErrorKind, + is_transient_http_status_code, is_transient_message_failure, map_hosted_voice_error, + report_error, report_error_or_expected, report_warning_message, ExpectedErrorKind, API_KEY_REJECTED_PREFIX, BACKEND_UNAVAILABLE_PREFIX, REPORT_ERROR_TRACING_TARGET, USAGE_PROBE_BACKOFF_PREFIX, }; diff --git a/crates/openhuman-tinyhumans/src/client/classify.rs b/crates/openhuman-tinyhumans/src/client/classify.rs index bd69605c64..b38b3c0ff5 100644 --- a/crates/openhuman-tinyhumans/src/client/classify.rs +++ b/crates/openhuman-tinyhumans/src/client/classify.rs @@ -5,8 +5,7 @@ use crate::transport::BackendTransportError; use anyhow::{Context, Result}; use openhuman_embed::__host::core::observability::{ - contains_transient_transport_phrase, is_transient_http_status_code, - is_voice_provider_user_state_message, report_error, + contains_transient_transport_phrase, is_transient_http_status_code, report_error, }; use openhuman_embed::__host::util::truncate_at_byte_boundary; use reqwest::Method; @@ -210,25 +209,7 @@ impl BackendClient { let is_transient_infra = is_transient_http_status_code(status_code); let is_budget_exhausted = status_code == 400 && tinyinference_providers::is_budget_exhausted_message(&text); - // Upstream voice-provider account/key problems (ElevenLabs - // `payment_required`, key ID pasted as key) wrapped in a backend 400 - // are user/upstream config, not code defects (TAURI-RUST-15BM/-15CT). - let is_voice_provider_config = - status_code == 400 && is_voice_provider_user_state_message(&text); - if is_voice_provider_config { - tracing::warn!( - domain = "backend_api", - operation = "authed_json", - method = method.as_str(), - path = url.path(), - status = status_code, - failure = "non_2xx", - kind = "voice_provider_config", - "[backend_api] voice-provider config 400 on {} {}: not reporting to Sentry", - method.as_str(), - url.path(), - ); - } else if is_budget_exhausted { + if is_budget_exhausted { tracing::info!( method = method.as_str(), path = url.path(), diff --git a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs index d5d1db02fa..7d799a5fa1 100644 --- a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs +++ b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs @@ -193,9 +193,10 @@ pub async fn synthesize_reply( ) .await .map_err(flatten_authed_error) - // Known upstream ElevenLabs account/key problems become an actionable - // user-facing message (TAURI-RUST-15BM); other errors pass through. - .map_err(openhuman_embed::__host::core::observability::map_voice_provider_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(openhuman_embed::__host::core::observability::map_hosted_voice_error)?; let result = normalize_response(&raw); debug!( From 6e151d7ec1ba8f88588bc2af841778d8c41ebfae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:18:30 +0000 Subject: [PATCH 3/9] fix(voice): recognise every backend ElevenLabs failure wrapper as hosted The backend wraps upstream ElevenLabs failures three ways: "ElevenLabs request failed (", "ElevenLabs streaming TTS failed (" and "ElevenLabs transcription failed (". Only the first was treated as hosted, so a hosted STT payment_required (TAURI-RUST-15CT) was still read as a BYOK billing problem and demoted. Match all three. Co-Authored-By: Claude Opus 5.5 --- ...error_classification_voice_provider_tests.rs | 12 ++++++++++-- .../core/observability_voice_provider_errors.rs | 17 +++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) 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 index abfcf64f9e..1c09a462ff 100644 --- 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 @@ -4,7 +4,11 @@ use super::*; /// 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. -const HOSTED_PAYMENT_STT: &str = r#"transcription request failed (400 Bad Request): {"success":false,"error":"ElevenLabs request failed (401): payment_required — \"Your subscription has a failed or incomplete payment\"","errorCode":"BAD_REQUEST"}"#; +/// 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. const BYOK_PAYMENT_TTS: &str = r#"[voice-tts] elevenlabs 401 payment_required: "Your subscription has a failed or incomplete payment""#; /// TAURI-RUST-1635: a BYOK user pasted the key ID. @@ -38,7 +42,11 @@ fn key_id_maps_and_demotes() { 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] { + 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!( diff --git a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs index e3c34cccc1..dad56677c9 100644 --- a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs +++ b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs @@ -27,11 +27,20 @@ pub const VOICE_PROVIDER_KEY_ID_USER_MESSAGE: &str = pub const HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE: &str = "The voice service is temporarily unavailable. Please try again later."; -/// Whether `lower` (ASCII-lowercased) is the TinyHumans backend's wrapping of -/// an upstream ElevenLabs failure (`elevenlabsService` in the backend), i.e. a -/// hosted request on the server's own ElevenLabs account. +/// The TinyHumans backend's wrappings of an upstream ElevenLabs failure +/// (`elevenlabsService` in the backend: TTS, streaming TTS, transcription). +const HOSTED_ELEVENLABS_FAILURE_MARKERS: &[&str] = &[ + "elevenlabs request failed (", + "elevenlabs streaming tts failed (", + "elevenlabs transcription failed (", +]; + +/// Whether `lower` (ASCII-lowercased) is a hosted request refused on the +/// backend's own ElevenLabs account. fn is_hosted_elevenlabs_failure(lower: &str) -> bool { - lower.contains("elevenlabs request failed (") + HOSTED_ELEVENLABS_FAILURE_MARKERS + .iter() + .any(|marker| lower.contains(marker)) } /// Map a raw BYOK voice-provider error to an actionable user-facing message, From e1183d06090b1dac42684c8cf574b218aa1657fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:12:29 +0000 Subject: [PATCH 4/9] docs(findings): link the voice-error PR and the backend fix Co-Authored-By: Claude Opus 5.5 --- FINDINGS-voiceerr.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FINDINGS-voiceerr.md b/FINDINGS-voiceerr.md index 5f181f9001..39bdedd466 100644 --- a/FINDINGS-voiceerr.md +++ b/FINDINGS-voiceerr.md @@ -1,6 +1,6 @@ # FINDINGS: voice-provider error handling (Workstream C) -Branch: `fix/voice-provider-error-handling` (GitHub #8502, #8503). Not pushed. +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 @@ -26,8 +26,8 @@ hides an outage from the only people who can fix it. The first commit did both. `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 should also surface upstream 401/402 as a 5xx (or a distinct `errorCode`) and alert on it -(backend repo, not changed here). +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`), From 2c98c52ae58b6c579225f9098bdbf06fc4dd7910 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:01:47 +0000 Subject: [PATCH 5/9] fix(voice): scope BYOK matching to the ElevenLabs client, stop logging raw bodies The key-ID and payment matches now require the ElevenLabs client's own error prefix ([voice-stt|tts] elevenlabs error), so OpenAI-compatible and Deepgram errors never get ElevenLabs guidance or the ProviderUserState demotion. Test strings use the client's real format. The mapping warnings log a fixed category instead of the raw error, which carries the upstream body. Co-Authored-By: Claude Opus 5.5 --- ...ror_classification_voice_provider_tests.rs | 26 ++++++- ...rvability_expected_error_classification.rs | 5 +- .../observability_voice_provider_errors.rs | 69 +++++++++++++------ 3 files changed, 73 insertions(+), 27 deletions(-) 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 index 1c09a462ff..0f23455ed9 100644 --- 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 @@ -9,10 +9,11 @@ const HOSTED_PAYMENT_STT: &str = r#"transcription request failed (400 Bad Reques /// 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. -const BYOK_PAYMENT_TTS: &str = r#"[voice-tts] elevenlabs 401 payment_required: "Your subscription has a failed or incomplete payment""#; +/// 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 400 invalid_api_key: "API key ID used as API key - only valid API keys can be used. API keys start with 'sk_'""#; +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() { @@ -102,3 +103,22 @@ fn map_voice_provider_error_passes_unknown_through() { 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 eb37e298c9..9deb5abcf8 100644 --- a/crates/openhuman-core/src/core/observability_expected_error_classification.rs +++ b/crates/openhuman-core/src/core/observability_expected_error_classification.rs @@ -61,8 +61,9 @@ pub fn expected_error_kind(message: &str) -> Option { if is_wallet_not_configured_message(&lower) { return Some(ExpectedErrorKind::WalletNotConfigured); } - // TAURI-RUST-15BM / -15CT / -1635 — ElevenLabs account payment failure or a - // key ID pasted as the API key (upstream/user config, not a code defect). + // 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`. diff --git a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs index dad56677c9..2f73911ff2 100644 --- a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs +++ b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs @@ -35,6 +35,40 @@ const HOSTED_ELEVENLABS_FAILURE_MARKERS: &[&str] = &[ "elevenlabs transcription failed (", ]; +/// 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 is_hosted_elevenlabs_failure(lower) + || !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 + } +} + /// Whether `lower` (ASCII-lowercased) is a hosted request refused on the /// backend's own ElevenLabs account. fn is_hosted_elevenlabs_failure(lower: &str) -> bool { @@ -43,26 +77,15 @@ fn is_hosted_elevenlabs_failure(lower: &str) -> bool { .any(|marker| lower.contains(marker)) } -/// Map a raw BYOK voice-provider error to an actionable user-facing message, -/// or `None` when the error is not one of the known user-config conditions. -/// Hosted failures are never matched: see the module comment. -/// -/// Matching is anchored on the upstream wording only; response bodies are not -/// assumed to have any other structure. +/// 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> { - let lower = message.to_ascii_lowercase(); - if is_hosted_elevenlabs_failure(&lower) { - return None; - } - if lower.contains("api key id used as api key") { - return Some(VOICE_PROVIDER_KEY_ID_USER_MESSAGE); - } - if lower.contains("elevenlabs") - && (lower.contains("payment_required") || lower.contains("failed or incomplete payment")) - { - return Some(VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE); + 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), } - None } /// Whether `message` is a raw BYOK voice-provider config error or one of our @@ -76,12 +99,14 @@ pub fn is_voice_provider_user_state_message(message: &str) -> bool { } /// Replace a known BYOK voice-provider error with its user-facing message, -/// logging the raw error at `warn`. Unknown errors pass through unchanged. +/// 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] upstream account/key problem mapped to user message: {raw}" + "[voice-provider] ElevenLabs account/key problem mapped to user message: {condition:?}" ); user.to_string() } @@ -92,11 +117,11 @@ pub fn map_voice_provider_error(raw: String) -> String { /// Replace a hosted voice error caused by the backend's upstream provider with /// [`HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE`]. Only for call sites whose raw /// failure has already been reported (`authed_json` reports every non-2xx it -/// does not classify); the raw error is logged at `warn` here. Other errors +/// does not classify), so only a fixed category is logged here. Other errors /// pass through unchanged. pub fn map_hosted_voice_error(raw: String) -> String { if is_hosted_elevenlabs_failure(&raw.to_ascii_lowercase()) { - tracing::warn!("[voice-provider] hosted upstream provider failure: {raw}"); + tracing::warn!("[voice-provider] hosted upstream provider failure mapped to user message"); HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE.to_string() } else { raw From f05c2c12ec370080fecc55001ca05e6a79a50e34 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 19:48:00 +0530 Subject: [PATCH 6/9] fix(voice): move hosted ElevenLabs mapping to tinyhumans, stop re-reporting it Review follow-ups on #8521: - The core no longer parses the TinyHumans backend's ElevenLabs wrappers. HOSTED_ELEVENLABS_FAILURE_MARKERS, map_hosted_voice_error and the neutral HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE move into voice/reply_speech.rs; the core keeps only the host-neutral BYOK classification (gated on the BYOK client's own error prefixes), and the openhuman-embed re-export is dropped. - authed_json already reported (or demoted as transient) the failure the neutral message replaces. The tinyhumans backend-error classifier now marks that message backend-owned and not reportable, so the RPC boundary demotes it instead of sending a second event. --- ...ror_classification_voice_provider_tests.rs | 15 ------ .../observability_voice_provider_errors.rs | 51 +++---------------- crates/openhuman-embed/src/host_internals.rs | 4 +- .../src/inference/backend_error_code.rs | 13 +++++ .../src/inference/backend_error_code_tests.rs | 12 +++++ .../src/voice/reply_speech.rs | 36 ++++++++++++- .../src/voice/reply_speech_tests.rs | 13 +++++ 7 files changed, 81 insertions(+), 63 deletions(-) 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 index 0f23455ed9..814f15de30 100644 --- 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 @@ -59,21 +59,6 @@ fn hosted_payment_required_is_not_user_state() { } } -#[test] -fn hosted_upstream_failure_maps_to_neutral_message() { - assert_eq!( - map_hosted_voice_error(HOSTED_PAYMENT_TTS.to_string()), - HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE - ); - let other = "POST /openai/v1/audio/speech failed (400 Bad Request): {\"error\":\"x\"}"; - assert_eq!(map_hosted_voice_error(other.to_string()), other); - // The neutral message is not demoted. - assert_eq!( - expected_error_kind(HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE), - None - ); -} - #[test] fn mapped_user_messages_stay_demoted_at_rpc_boundary() { for m in [ diff --git a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs index 2f73911ff2..3a49abde6c 100644 --- a/crates/openhuman-core/src/core/observability_voice_provider_errors.rs +++ b/crates/openhuman-core/src/core/observability_voice_provider_errors.rs @@ -6,12 +6,9 @@ // 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** (`/openai/v1/audio/*` on the TinyHumans backend, which always -// calls ElevenLabs with the server's own key and wraps any upstream failure -// as `ElevenLabs request failed (): `): a `payment_required` -// there (TAURI-RUST-15BM / -15CT) is TinyHumans' own account, which the user -// cannot fix. It stays a reported error, and the user is only told the -// service is unavailable. +// - **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. @@ -22,19 +19,6 @@ pub const VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE: &str = 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."; -/// User-facing message for a hosted voice request the backend's own upstream -/// provider refused. Nothing the user can change, so no instruction. -pub const HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE: &str = - "The voice service is temporarily unavailable. Please try again later."; - -/// The TinyHumans backend's wrappings of an upstream ElevenLabs failure -/// (`elevenlabsService` in the backend: TTS, streaming TTS, transcription). -const HOSTED_ELEVENLABS_FAILURE_MARKERS: &[&str] = &[ - "elevenlabs request failed (", - "elevenlabs streaming tts failed (", - "elevenlabs transcription failed (", -]; - /// 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 @@ -53,10 +37,9 @@ enum ByokCondition { /// Classify `lower` (ASCII-lowercased) as a BYOK ElevenLabs user-config error. fn byok_condition(lower: &str) -> Option { - if is_hosted_elevenlabs_failure(lower) - || !BYOK_ELEVENLABS_ERROR_PREFIXES - .iter() - .any(|prefix| lower.contains(prefix)) + if !BYOK_ELEVENLABS_ERROR_PREFIXES + .iter() + .any(|prefix| lower.contains(prefix)) { return None; } @@ -69,14 +52,6 @@ fn byok_condition(lower: &str) -> Option { } } -/// Whether `lower` (ASCII-lowercased) is a hosted request refused on the -/// backend's own ElevenLabs account. -fn is_hosted_elevenlabs_failure(lower: &str) -> bool { - HOSTED_ELEVENLABS_FAILURE_MARKERS - .iter() - .any(|marker| lower.contains(marker)) -} - /// 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 @@ -113,17 +88,3 @@ pub fn map_voice_provider_error(raw: String) -> String { None => raw, } } - -/// Replace a hosted voice error caused by the backend's upstream provider with -/// [`HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE`]. Only for call sites whose raw -/// failure has already been reported (`authed_json` reports every non-2xx it -/// does not classify), so only a fixed category is logged here. Other errors -/// pass through unchanged. -pub fn map_hosted_voice_error(raw: String) -> String { - if is_hosted_elevenlabs_failure(&raw.to_ascii_lowercase()) { - tracing::warn!("[voice-provider] hosted upstream provider failure mapped to user message"); - HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE.to_string() - } else { - raw - } -} diff --git a/crates/openhuman-embed/src/host_internals.rs b/crates/openhuman-embed/src/host_internals.rs index 3eca869b68..8b0c0a1c7d 100644 --- a/crates/openhuman-embed/src/host_internals.rs +++ b/crates/openhuman-embed/src/host_internals.rs @@ -39,8 +39,8 @@ pub mod core { pub use openhuman_core::core::observability::{ contains_transient_transport_phrase, expected_error_kind, is_api_key_rejected_message, is_session_expired_message, is_suppressed_usage_probe_backoff, - is_transient_http_status_code, is_transient_message_failure, map_hosted_voice_error, - report_error, report_error_or_expected, report_warning_message, ExpectedErrorKind, + is_transient_http_status_code, is_transient_message_failure, report_error, + report_error_or_expected, report_warning_message, ExpectedErrorKind, API_KEY_REJECTED_PREFIX, BACKEND_UNAVAILABLE_PREFIX, REPORT_ERROR_TRACING_TARGET, USAGE_PROBE_BACKOFF_PREFIX, }; diff --git a/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs b/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs index febe3ae1d4..b5966b6cf3 100644 --- a/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs +++ b/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs @@ -137,6 +137,19 @@ pub fn body_flags_malformed(err: &str) -> bool { /// 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 err.contains(crate::voice::reply_speech::HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE) { + 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..ec7d32b39b 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,15 @@ 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}"); + } +} diff --git a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs index 7d799a5fa1..4e3645e354 100644 --- a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs +++ b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs @@ -30,6 +30,40 @@ 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 (", +]; + +/// Replace a hosted voice error caused by the backend's upstream provider with +/// [`HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE`], logging only a fixed category. +/// Other errors pass through unchanged. +pub(crate) fn map_hosted_voice_error(raw: String) -> String { + let lower = raw.to_ascii_lowercase(); + if HOSTED_ELEVENLABS_FAILURE_MARKERS + .iter() + .any(|marker| lower.contains(marker)) + { + warn!("{LOG_PREFIX} hosted upstream provider failure mapped to user message"); + HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE.to_string() + } else { + 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 @@ -196,7 +230,7 @@ pub async fn synthesize_reply( // 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(openhuman_embed::__host::core::observability::map_hosted_voice_error)?; + .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..4f87e26bb8 100644 --- a/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs +++ b/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs @@ -56,3 +56,16 @@ 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); +} From 4bab3681e569d91ac2d65e85209e28264f8e8697 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 20:05:42 +0530 Subject: [PATCH 7/9] style(tinyhumans): rustfmt the hosted-voice classifier test --- .../src/inference/backend_error_code_tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 ec7d32b39b..df292209e0 100644 --- a/crates/openhuman-tinyhumans/src/inference/backend_error_code_tests.rs +++ b/crates/openhuman-tinyhumans/src/inference/backend_error_code_tests.rs @@ -293,9 +293,15 @@ 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}")] { + for err in [ + message.to_string(), + format!("voice.reply_synthesize: {message}"), + ] { let classification = classify(None, &err); - assert!(classification.backend_owned && !classification.reportable, "{err}"); + assert!( + classification.backend_owned && !classification.reportable, + "{err}" + ); assert_eq!(classification.kind, None, "{err}"); } } From 1b7c03c6f717d10b93955d8647843b847c001787 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:59:07 +0000 Subject: [PATCH 8/9] fix(tinyhumans): only exempt the exact hosted-voice copy from reporting classify() matched the neutral hosted-voice sentence anywhere in an error, so another provider's error quoting it was marked backend-owned and never reported. Accept the copy only bare or behind an RPC method name. Co-Authored-By: Claude Opus 5.5 --- .../src/inference/backend_error_code.rs | 22 ++++++++++++++++++- .../src/inference/backend_error_code_tests.rs | 9 ++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs b/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs index b5966b6cf3..ce0176538b 100644 --- a/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs +++ b/crates/openhuman-tinyhumans/src/inference/backend_error_code.rs @@ -135,12 +135,32 @@ 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 err.contains(crate::voice::reply_speech::HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE) { + if is_hosted_voice_unavailable_copy(err) { return BackendErrorClassification { provider_owned: false, backend_owned: true, 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 df292209e0..7cd3b5dc83 100644 --- a/crates/openhuman-tinyhumans/src/inference/backend_error_code_tests.rs +++ b/crates/openhuman-tinyhumans/src/inference/backend_error_code_tests.rs @@ -304,4 +304,13 @@ fn hosted_voice_unavailable_copy_is_backend_owned_and_not_reported_again() { ); 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}"); + } } From 75b90d2532ab02cc6347d377bcb4856d99b97d0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:20:52 +0000 Subject: [PATCH 9/9] fix(voice): keep hosted request errors actionable, log an upstream category map_hosted_voice_error replaced every backend ElevenLabs failure with the neutral "temporarily unavailable" copy, including request errors the user can fix (voice_not_found, text too long). Read the upstream status from the wrapper and map only failures of the backend's own account or service (billing codes or 402, 401/403/412, 429, 5xx); everything else keeps its message. The warning now records an allowlisted category and the upstream status instead of nothing distinguishing, still without the body. Co-Authored-By: Claude Opus 5.5 --- FINDINGS-voiceerr.md | 14 +++-- .../src/voice/reply_speech.rs | 62 ++++++++++++++++--- .../src/voice/reply_speech_tests.rs | 47 ++++++++++++++ 3 files changed, 108 insertions(+), 15 deletions(-) diff --git a/FINDINGS-voiceerr.md b/FINDINGS-voiceerr.md index 39bdedd466..1dc6da176f 100644 --- a/FINDINGS-voiceerr.md +++ b/FINDINGS-voiceerr.md @@ -17,9 +17,12 @@ hides an outage from the only people who can fix it. The first commit did both. - 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 (`reply_speech.rs`) now uses `map_hosted_voice_error`, which swaps the raw backend line for - "The voice service is temporarily unavailable. Please try again later." (the raw error was already - reported by `authed_json` and is logged at `warn`). +- 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 @@ -47,10 +50,9 @@ backend now surfaces upstream billing/account refusals as a 503 and alerts on th 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) and `map_hosted_voice_error()` (hosted, neutral message). + - `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, neutral). - `openhuman-embed/src/host_internals.rs` re-exports `map_hosted_voice_error`. +- 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). diff --git a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs index 4e3645e354..b377169c21 100644 --- a/crates/openhuman-tinyhumans/src/voice/reply_speech.rs +++ b/crates/openhuman-tinyhumans/src/voice/reply_speech.rs @@ -48,19 +48,63 @@ const HOSTED_ELEVENLABS_FAILURE_MARKERS: &[&str] = &[ "elevenlabs transcription failed (", ]; -/// Replace a hosted voice error caused by the backend's upstream provider with -/// [`HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE`], logging only a fixed category. -/// Other errors pass through unchanged. -pub(crate) fn map_hosted_voice_error(raw: String) -> String { +/// 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(); - if HOSTED_ELEVENLABS_FAILURE_MARKERS + let (start, marker) = HOSTED_ELEVENLABS_FAILURE_MARKERS .iter() - .any(|marker| lower.contains(marker)) + .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)) { - warn!("{LOG_PREFIX} hosted upstream provider failure mapped to user message"); - HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE.to_string() + HostedUpstreamFailure::Billing + } else if matches!(status, 401 | 403 | 412) { + HostedUpstreamFailure::Account + } else if status == 429 { + HostedUpstreamFailure::RateLimited + } else if status >= 500 { + HostedUpstreamFailure::Service } else { - raw + 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, } } diff --git a/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs b/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs index 4f87e26bb8..fbe59931e6 100644 --- a/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs +++ b/crates/openhuman-tinyhumans/src/voice/reply_speech_tests.rs @@ -69,3 +69,50 @@ fn hosted_upstream_failure_maps_to_neutral_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); + } +}