diff --git a/src/openhuman/channels/providers/web_errors.rs b/src/openhuman/channels/providers/web_errors.rs index dc5cdc2ca..58f89819b 100644 --- a/src/openhuman/channels/providers/web_errors.rs +++ b/src/openhuman/channels/providers/web_errors.rs @@ -17,13 +17,33 @@ pub(crate) fn is_inference_budget_exceeded_error(message: &str) -> bool { let normalized = BUDGET_ERROR_NORMALIZE_RE .replace_all(&message.trim().to_ascii_lowercase(), " ") .into_owned(); - BUDGET_ERROR_PATTERNS + if BUDGET_ERROR_PATTERNS .iter() .any(|pattern| pattern.is_match(&normalized)) + { + return true; + } + // Align with the canonical OpenHuman-backend budget detector + // (`billing_error::is_budget_exhausted_message`) so the managed + // no-credits response — a 400 carrying "Insufficient budget" / + // "Insufficient balance" — surfaces the actionable budget message + // below instead of the generic "Something went wrong" apology + // (issue #3088). Without this, an Ollama user with zero credits and + // routing still on Managed sees an opaque "provider error" and has no + // way to self-diagnose that they must top up or switch routing. + crate::openhuman::inference::provider::is_budget_exhausted_message(message) } pub(crate) fn inference_budget_exceeded_user_message() -> &'static str { - "I don't have any budget available right now. Please top up your credits or choose a plan to continue." + // Keep the literal "top up" / "credits" tokens (asserted by + // `budget_exceeded_copy_mentions_top_up`) and add the self-diagnosis + // path for issue #3088: a user who enabled a local model but left + // routing on Managed needs to know they can switch to their own model + // rather than being stuck. We guide, never auto-switch — the user's + // routing choice in Settings is respected. + "You're out of credits, so I can't run the managed (cloud) model right now. \ + You can top up your credits or pick a plan to continue — or, if you've enabled a \ + local model like Ollama, switch routing to \"Use Your Own Models\" in Settings → AI Configuration." } pub(crate) fn generic_inference_error_user_message() -> &'static str { @@ -379,6 +399,12 @@ pub(crate) fn classify_inference_error(err: &str) -> ClassifiedError { } else if lower.contains("402") || lower.contains("payment required") || lower.contains("insufficient balance") + // Issue #3088: the OpenHuman managed backend reports no-credits as a + // 400 with "Insufficient budget" (not a 402), which previously fell + // through to the generic "Something went wrong" branch. Catch the + // canonical budget phrases here so the user gets the actionable + // top-up / switch-to-your-own-model guidance instead. + || is_inference_budget_exceeded_error(err) { // `openhuman_billing` means OpenHuman's own credit/quota system — // a 402 carrying the "openhuman" envelope (or no envelope at all, @@ -392,7 +418,7 @@ pub(crate) fn classify_inference_error(err: &str) -> ClassifiedError { }; ClassifiedError { error_type: "budget_exhausted", - message: with_provider_detail("Insufficient credits. Please top up to continue.", err), + message: with_provider_detail(inference_budget_exceeded_user_message(), err), source, retryable: false, retry_after_ms: None, diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/channels/providers/web_tests.rs index e2d206528..2f2b1cfd9 100644 --- a/src/openhuman/channels/providers/web_tests.rs +++ b/src/openhuman/channels/providers/web_tests.rs @@ -134,6 +134,16 @@ fn detects_backend_budget_exhaustion_error() { assert!(is_inference_budget_exceeded_error( "provider error: budget exceeded, please add credits" )); + // Issue #3088: the OpenHuman managed backend reports no-credits as a + // 400 carrying these canonical phrases (see `billing_error.rs`). They + // were previously NOT recognised here, so the error fell through to the + // generic "Something went wrong" branch. They must now match. + assert!(is_inference_budget_exceeded_error( + "openhuman API error (400 Bad Request): Insufficient budget" + )); + assert!(is_inference_budget_exceeded_error( + "openhuman API error (400 Bad Request): Insufficient balance" + )); assert!(!is_inference_budget_exceeded_error( "OpenHuman API error (500): Internal server error" )); @@ -144,6 +154,36 @@ fn budget_exceeded_copy_mentions_top_up() { let message = inference_budget_exceeded_user_message(); assert!(message.contains("top up")); assert!(message.contains("credits")); + // Issue #3088: the copy must guide the user to the self-service fix — + // switching routing to their own local model — so an Ollama user with + // no credits can self-diagnose. We guide, never auto-switch. + assert!(message.contains("Use Your Own Models")); + assert!(message.contains("Settings")); +} + +#[test] +fn classify_inference_error_managed_insufficient_budget_400_is_budget_exhausted() { + // Issue #3088: a managed (OpenHuman backend) no-credits failure arrives + // as a 400 with "Insufficient budget" — NOT a 402. It previously fell + // through to the generic `inference` branch ("Something went wrong"), + // leaving the user unable to self-diagnose. It must now classify as + // budget_exhausted with actionable, non-retryable copy. + let raw = "openhuman API error (400 Bad Request): Insufficient budget"; + let classified = classify_inference_error(raw); + assert_eq!(classified.error_type, "budget_exhausted"); + assert_eq!( + classified.source, "openhuman_billing", + "the OpenHuman backend's own credit system is the origin" + ); + assert!( + !classified.retryable, + "out of credits — retrying the same prompt won't help" + ); + assert!( + classified.message.contains("Use Your Own Models"), + "must guide the user to switch routing: {}", + classified.message + ); } #[test] diff --git a/tests/channels_provider_leftovers_raw_coverage_e2e.rs b/tests/channels_provider_leftovers_raw_coverage_e2e.rs index 84b300832..496b8455a 100644 --- a/tests/channels_provider_leftovers_raw_coverage_e2e.rs +++ b/tests/channels_provider_leftovers_raw_coverage_e2e.rs @@ -318,11 +318,14 @@ async fn web_round19_covers_classifier_variants_and_cancel_cleanup() { assert_eq!(auth.source, "config"); assert!(!auth.retryable); + // Issue #3088: budget-signal strings now classify as `budget_exhausted` + // instead of falling through to the generic `inference` branch — the + // user gets an actionable "top up or switch routing" message. let budget = web_test_support::classify_error_for_test( "inference budget exceeded: monthly limit reached", ); - assert_eq!(budget.error_type, "inference"); - assert_eq!(budget.source, "provider"); + assert_eq!(budget.error_type, "budget_exhausted"); + assert_eq!(budget.source, "openhuman_billing"); let network = web_test_support::classify_error_for_test( "request error: dns error while trying to connect",