fix(inference): surface actionable error when Managed route fails with no credits (#3121)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Steven Enamakel's Droid
2026-06-01 14:03:42 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 1f463dd7ea
commit e9c38ea615
3 changed files with 74 additions and 5 deletions
+29 -3
View File
@@ -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,
@@ -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]
@@ -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",