Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions FINDINGS-voiceerr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# FINDINGS: voice-provider error handling (Workstream C)

Branch: `fix/voice-provider-error-handling` (GitHub #8502, #8503). PR: tinyhumansai/openhuman#8521.

## Correction (second commit): hosted vs BYOK
CONTEXT.md treated `payment_required` as a user config problem. The backend source
(`tinyhumansai/backend` @ 6af8f69, read-only clone) shows that is wrong for the hosted routes:
- `src/services/elevenlabs/elevenlabsService.ts` always calls ElevenLabs with the **server's own**
`ELEVENLABS_API_KEY` (all 5 call sites) and wraps any upstream failure as
`BadRequestError("ElevenLabs request failed (<status>): <body>")` (line 107), i.e. a 400.
- `src/middlewares/errorHandler.ts` does not report 4xx to the backend's Sentry, so the client report is
the only signal anyone gets.

So **TAURI-RUST-15BM (TTS) and -15CT (STT) are TinyHumans' own ElevenLabs subscription with a failed
payment**, not the users'. Telling 44 + 17 users to "update billing" is wrong, and demoting the event
hides an outage from the only people who can fix it. The first commit did both. Now:
- Hosted failures (detected by the backend's `ElevenLabs request failed (` wrapping) are never matched by
`voice_provider_user_message` / `is_voice_provider_user_state_message`, so they stay Sentry errors.
- The `classify.rs` demotion from the first commit is reverted: `authed_json` reports them again.
- Hosted TTS (`openhuman-tinyhumans` `voice/reply_speech.rs`) uses `map_hosted_voice_error`. Only failures
of the backend's own ElevenLabs account or service (billing codes or 402, 401/403/412, 429, 5xx, read from
the wrapper's upstream status) become "The voice service is temporarily unavailable. Please try again
later."; request errors the user can fix (`voice_not_found`, text too long, other 4xx) keep their message.
The raw error was already reported by `authed_json`; the mapping logs only the category and upstream status,
and the backend-error classifier marks the exact neutral message as not reportable again.
- Hosted STT (`cloud_transcribe.rs`) is left raw: the RPC boundary is its only report site, and mapping
there would strip the detail ops needs.
- The BYOK handling is kept: the external STT/TTS providers use the user's own key, so for them
`payment_required` ("Update billing") and the key-ID message (TAURI-RUST-1635) are correct, and demoted.

**The real fix for 15BM / 15CT is operational**: settle the TinyHumans ElevenLabs subscription. The
backend now surfaces upstream billing/account refusals as a 503 and alerts on them
(tinyhumansai/backend#1438).

## What I found
- The ElevenLabs HTTP clients live in the vendored `tinyinference-voice` crate (`external_stt.rs` / `external_tts.rs`),
which formats errors as `[voice-stt] elevenlabs error {status}: {body}` / `[voice-tts] ... `. Not edited (vendor).
The host wraps them in `crates/openhuman-core/src/voice/factory/{stt,tts}_providers.rs`.
- Hosted TTS (`POST /openai/v1/audio/speech`) goes `openhuman-tinyhumans/src/voice/reply_speech.rs` ->
`BackendClient::authed_json` -> `client/classify.rs::finish_authed_json`. Any non-2xx that is not 401, budget, or
transient goes to `report_error` (Sentry error) with only the body *shape*.
- Hosted STT goes `cloud_transcribe.rs` -> `tinyinference_voice::cloud::transcribe` (not `authed_json`); only 401 is
classified there.
- Existing convention: `core/observability*.rs` `expected_error_kind` -> `ExpectedErrorKind::ProviderUserState`
(logged at info, not sent to Sentry).

## What I changed (net)
- `crates/openhuman-core/src/core/observability_voice_provider_errors.rs` (included into `observability.rs`):
- `voice_provider_user_message()`: BYOK `payment_required` -> "Your ElevenLabs subscription has a payment issue.
Update billing to continue."; "API key ID used as API key" -> "That looks like an ElevenLabs key ID, not a key.
Keys start with `sk_`. Re-enter the API key."; hosted wrapping -> `None`.
- `is_voice_provider_user_state_message()`: raw BYOK text and the two mapped messages (RPC re-report stays demoted).
- `map_voice_provider_error()` (BYOK). The hosted mapping lives in `openhuman-tinyhumans` (above).
- `expected_error_kind` returns `ProviderUserState` for the BYOK cases only.
- Mapping applied in `ExternalSttProvider`, `ExternalTtsProvider` (BYOK) and `reply_speech.rs` (hosted).
- Tests: `observability_error_classification_voice_provider_tests.rs` (BYOK payment, key ID, hosted TTS/STT not
claimed, hosted neutral mapping, mapped-message re-report, pass-through, no over-match).

## TAURI-RUST-112J classification
**Backend / upstream, not a client defect, and probably not user credits.** Same route (`/openai/v1/audio/speech`),
400, envelope `error,errorCode,success` (no `details`). The backend's low-credit answer on this route is
`BadRequestError('Insufficient budget')` (`controllers/inference/textToSpeech.ts:57`), which the client's existing
budget filter already keeps out of Sentry (present in 0.64.15), so a reported 400 here is most likely another
`ElevenLabs request failed (...)` upstream failure (voice not found, quota, the same billing problem). Unknown 400s
still report. To close it: read the body from one user's daily log (the error string carries the first 512 bytes),
or check whether 112J drops once the ElevenLabs billing is fixed.

## Verification (limited, please read)
- `cargo check -p openhuman` fails on a **clean** `upstream/main` too (~400 errors): vendored submodules are pinned to
commits older than the API the host code uses (e.g. `tinychannels_bus::module_progressive::ChannelOutputEvent`).
This blocks in-tree `cargo check/test` for `openhuman`, `openhuman-tinyhumans` and `openhuman-embed`. vendor/ not
touched; needs submodule pins bumped upstream.
- The corrected `observability_voice_provider_errors.rs` was compiled verbatim in a scratch crate (stubbed
`tracing`) with the test file's real error strings: BYOK payment and key ID map, hosted TTS/STT are not
claimed and pass through, hosted maps to the neutral message, no over-match. All pass. The parts that need
`expected_error_kind` (the demotion asserts) and the wiring edits (`reply_speech.rs`, embed re-export,
`classify.rs` revert) were formatted with rustfmt and checked by reading, NOT compiled in-tree.
- Note: sccache `EAGAIN` occurs with default parallelism on this box; use `cargo check -j 12`.
1 change: 1 addition & 0 deletions crates/openhuman-core/src/core/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
use super::*;

/// TAURI-RUST-15BM: the hosted TTS route wrapping a refusal of the backend's
/// own ElevenLabs account.
const HOSTED_PAYMENT_TTS: &str = r#"POST /openai/v1/audio/speech failed (400 Bad Request): {"success":false,"error":"ElevenLabs request failed (401): {\"detail\":{\"status\":\"payment_required\",\"message\":\"Your subscription has a failed or incomplete payment\"}}","errorCode":"BAD_REQUEST"}"#;
/// TAURI-RUST-15CT: the same refusal on the hosted transcription route.
/// The backend's transcription wrapper differs from the TTS one.
const HOSTED_PAYMENT_STT: &str = r#"transcription request failed (400 Bad Request): {"success":false,"error":"ElevenLabs transcription failed (401): {\"detail\":{\"status\":\"payment_required\",\"message\":\"Your subscription has a failed or incomplete payment\"}}","errorCode":"BAD_REQUEST"}"#;
/// Streaming TTS uses a third wrapper.
const HOSTED_PAYMENT_STREAM_TTS: &str =
r#"ElevenLabs streaming TTS failed (401): {"detail":{"status":"payment_required"}}"#;
/// A BYOK request refused because the user's own subscription is unpaid, in the
/// ElevenLabs client's format (`[voice-tts] elevenlabs error {status}: {body}`).
const BYOK_PAYMENT_TTS: &str = r#"[voice-tts] elevenlabs error 401 Unauthorized: {"detail":{"status":"payment_required","message":"Your subscription has a failed or incomplete payment"}}"#;
/// TAURI-RUST-1635: a BYOK user pasted the key ID.
const KEY_ID_STT: &str = r#"[voice-stt] elevenlabs error 400 Bad Request: {"detail":{"status":"invalid_api_key","message":"API key ID used as API key - only valid API keys can be used. API keys start with 'sk_'"}}"#;

#[test]
fn byok_payment_required_maps_and_demotes() {
assert_eq!(
voice_provider_user_message(BYOK_PAYMENT_TTS),
Some(VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE)
);
assert_eq!(
expected_error_kind(BYOK_PAYMENT_TTS),
Some(ExpectedErrorKind::ProviderUserState)
);
}

#[test]
fn key_id_maps_and_demotes() {
assert_eq!(
voice_provider_user_message(KEY_ID_STT),
Some(VOICE_PROVIDER_KEY_ID_USER_MESSAGE)
);
assert_eq!(
expected_error_kind(KEY_ID_STT),
Some(ExpectedErrorKind::ProviderUserState)
);
}

#[test]
fn hosted_payment_required_is_not_user_state() {
// The backend's own account: no "update billing" message, and it keeps
// reaching Sentry so ops sees it.
for raw in [
HOSTED_PAYMENT_TTS,
HOSTED_PAYMENT_STT,
HOSTED_PAYMENT_STREAM_TTS,
] {
assert_eq!(voice_provider_user_message(raw), None, "{raw}");
assert!(!is_voice_provider_user_state_message(raw), "{raw}");
assert_ne!(
expected_error_kind(raw),
Some(ExpectedErrorKind::ProviderUserState),
"{raw}"
);
assert_eq!(map_voice_provider_error(raw.to_string()), raw);
}
}

#[test]
fn mapped_user_messages_stay_demoted_at_rpc_boundary() {
for m in [
VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE,
VOICE_PROVIDER_KEY_ID_USER_MESSAGE,
] {
assert_eq!(
expected_error_kind(m),
Some(ExpectedErrorKind::ProviderUserState),
"{m}"
);
}
}

#[test]
fn map_voice_provider_error_passes_unknown_through() {
assert_eq!(
map_voice_provider_error(BYOK_PAYMENT_TTS.to_string()),
VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE
);
let other = "[voice-tts] elevenlabs 500: upstream exploded";
assert_eq!(map_voice_provider_error(other.to_string()), other);
assert_eq!(expected_error_kind(other), None);
}

#[test]
fn unrelated_payment_required_is_not_claimed() {
assert_eq!(voice_provider_user_message("stripe payment_required"), None);
}

#[test]
fn other_byok_providers_are_not_claimed() {
// OpenAI-compatible and Deepgram styles share the external clients but use
// their own prefixes; the ElevenLabs guidance must not reach them.
for raw in [
r#"[voice-stt] external STT error 400 Bad Request: API key ID used as API key"#,
r#"[voice-tts] external TTS error 402 Payment Required: payment_required"#,
r#"[voice-stt] deepgram error 401 Unauthorized: API key ID used as API key"#,
] {
assert_eq!(voice_provider_user_message(raw), None, "{raw}");
assert_ne!(
expected_error_kind(raw),
Some(ExpectedErrorKind::ProviderUserState),
"{raw}"
);
assert_eq!(map_voice_provider_error(raw.to_string()), raw);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
let lower = message.to_ascii_lowercase();
// Explicit host ownership/reportability metadata precedes text heuristics.
Expand Down Expand Up @@ -62,6 +61,15 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
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);
Expand Down
2 changes: 2 additions & 0 deletions crates/openhuman-core/src/core/observability_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// ── Voice-provider (ElevenLabs) account / key misconfiguration ─────────────
//
// Two different owners hide behind the same ElevenLabs wording:
//
// - **BYOK** (the external STT/TTS providers, called with the user's own
// ElevenLabs key): `payment_required` is the user's own subscription and
// "API key ID used as API key" (TAURI-RUST-1635) is a pasted key ID. Both
// are user state: mapped to an actionable message and demoted from Sentry.
// - **Hosted** (a host's own voice backend calling ElevenLabs with its own
// key): not the user's account. Never matched here (only the BYOK client's
// error prefixes are); the host owns mapping and reporting those.

/// User-facing message for an ElevenLabs `payment_required` rejection of the
/// user's own (BYOK) key.
pub const VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE: &str =
"Your ElevenLabs subscription has a payment issue. Update billing to continue.";

/// User-facing message for an ElevenLabs key-ID-instead-of-key rejection.
pub const VOICE_PROVIDER_KEY_ID_USER_MESSAGE: &str =
"That looks like an ElevenLabs key ID, not a key. Keys start with `sk_`. Re-enter the API key.";

/// The BYOK ElevenLabs client's error prefixes (`tinyinference_voice`
/// `external_stt` / `external_tts`, ElevenLabs API style). The OpenAI-compatible
/// and Deepgram styles use different prefixes, so matching on these keeps the
/// ElevenLabs-specific messages off the other providers.
const BYOK_ELEVENLABS_ERROR_PREFIXES: &[&str] = &[
"[voice-stt] elevenlabs error ",
"[voice-tts] elevenlabs error ",
];

/// A BYOK voice-provider condition the user can fix.
#[derive(Debug, Clone, Copy)]
enum ByokCondition {
KeyId,
PaymentRequired,
}

/// Classify `lower` (ASCII-lowercased) as a BYOK ElevenLabs user-config error.
fn byok_condition(lower: &str) -> Option<ByokCondition> {
if !BYOK_ELEVENLABS_ERROR_PREFIXES
.iter()
.any(|prefix| lower.contains(prefix))
{
return None;
}
if lower.contains("api key id used as api key") {
Some(ByokCondition::KeyId)
} else if lower.contains("payment_required") || lower.contains("failed or incomplete payment") {
Some(ByokCondition::PaymentRequired)
} else {
None
}
}

/// Map a raw BYOK ElevenLabs error to an actionable user-facing message, or
/// `None` when the error is not one of the known user-config conditions.
/// Hosted failures and other providers are never matched: see the module
/// comment and [`BYOK_ELEVENLABS_ERROR_PREFIXES`].
pub fn voice_provider_user_message(message: &str) -> Option<&'static str> {
match byok_condition(&message.to_ascii_lowercase())? {
ByokCondition::KeyId => Some(VOICE_PROVIDER_KEY_ID_USER_MESSAGE),
ByokCondition::PaymentRequired => Some(VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE),
}
}

/// Whether `message` is a raw BYOK voice-provider config error or one of our
/// own mapped BYOK user messages (the latter so the RPC-boundary re-report of
/// the already-mapped string is demoted too).
pub fn is_voice_provider_user_state_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
voice_provider_user_message(&lower).is_some()
|| lower.contains(&VOICE_PROVIDER_PAYMENT_REQUIRED_USER_MESSAGE.to_ascii_lowercase())
|| lower.contains(&VOICE_PROVIDER_KEY_ID_USER_MESSAGE.to_ascii_lowercase())
}

/// Replace a known BYOK voice-provider error with its user-facing message,
/// logging only the condition at `warn` (the raw error carries the upstream
/// response body). Unknown errors pass through unchanged.
pub fn map_voice_provider_error(raw: String) -> String {
match voice_provider_user_message(&raw) {
Some(user) => {
let condition = byok_condition(&raw.to_ascii_lowercase());
tracing::warn!(
"[voice-provider] ElevenLabs account/key problem mapped to user message: {condition:?}"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
user.to_string()
}
None => raw,
}
}
3 changes: 2 additions & 1 deletion crates/openhuman-core/src/voice/factory/stt_providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion crates/openhuman-core/src/voice/factory/tts_providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions crates/openhuman-tinyhumans/src/inference/backend_error_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <copy>`). 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"),
Expand Down
Loading
Loading