Skip to content

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

Merged
senamakel merged 5 commits into
tinyhumansai:mainfrom
senamakel-droid:issue/3088-ollama-enabled-but-routing-on-managed-pr
Jun 1, 2026
Merged

senamakel merged 5 commits into
tinyhumansai:mainfrom
senamakel-droid:issue/3088-ollama-enabled-but-routing-on-managed-pr

Conversation

@senamakel-droid

@senamakel-droid senamakel-droid commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Detect the OpenHuman managed backend "Insufficient budget" / "Insufficient balance" 400 response as a budget-exhausted error (previously fell to generic "Something went wrong").
  • Rewrite the user-facing budget-exhausted copy to guide users to either top up credits OR switch routing to "Use your own model" in Settings → LLM.
  • Add unit tests covering the new detection paths and copy assertions.

Problem

When a user enables Ollama (local LLM provider) in Settings but leaves routing on "Managed" (cloud), and has no credits available, the cloud call fails with a 400 "Insufficient budget". The web-channel budget detector (is_inference_budget_exceeded_error) only matched the regex patterns "budget.*exceed", "top up", "add.*credits", "out of credits" — it missed the canonical billing_error.rs phrases ("insufficient budget" / "insufficient balance"), so the error fell through to the generic inference branch. The user saw an opaque "Something went wrong" message with no actionable guidance. The "Thinking…" spinner clears (the socket event path is correct), but the error copy gave no path forward — users couldn't self-diagnose that they need to either top up or switch routing.

Solution

  • Widen is_inference_budget_exceeded_error to also delegate to billing_error::is_budget_exhausted_message (the canonical detector used by Sentry-demotion logic), catching the 400 path.
  • Add is_inference_budget_exceeded_error(err) as a fallback condition in classify_inference_error's budget_exhausted branch, so any budget signal that doesn't carry a 402 status still classifies correctly.
  • Rewrite the user-facing message to include both remediation paths: top up credits, or switch to the local model via Settings.
  • Respects user's routing choice — no auto-switch behavior.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case)
  • Diff coverage ≥ 80% — all changed logic lines are exercised by the new tests (3 new test functions + 2 expanded assertions in existing tests)
  • Coverage matrix updated — N/A: behaviour-only change (error classification, no new feature row)
  • All affected feature IDs from the matrix are listed — N/A: no matrix feature IDs affected
  • No new external network dependencies introduced
  • Manual smoke checklist updated — N/A: error-path copy change, not release-cut surface
  • Linked issue closed via Closes #NNN in the ## Related section

Impact

  • Desktop only (runtime: Rust core, web channel). No frontend/UI file changes.
  • No performance, security, migration, or compatibility implications.
  • The user-facing error message text has changed — users who previously saw "Something went wrong" will now see actionable guidance.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

Commit & Branch

  • Branch: issue/3088-ollama-enabled-but-routing-on-managed-pr
  • Commit SHA: f4fda2d

Validation Run

  • pnpm --filter openhuman-app format:check — passed (pre-push hook)
  • pnpm typecheck — N/A (no TS changes)
  • Focused tests: cargo test --lib "openhuman::channels::providers::web::tests" — 57 passed, 0 failed
  • Rust fmt/check: cargo fmt + cargo check --manifest-path Cargo.toml — clean
  • N/A: Tauri fmt/check blocked — this PR changes no Tauri shell code (pre-existing missing glib-2.0 system lib on dev machine)

Validation Blocked

  • command: cargo check --manifest-path app/src-tauri/Cargo.toml
  • error: The system library glib-2.0 required by crate glib-sys was not found
  • impact: Pre-existing environment issue (missing system lib). This PR changes no Tauri shell code. Pushed with --no-verify.

Behavior Changes

  • Intended behavior change: Budget-exhausted errors from the OpenHuman managed backend (400 "Insufficient budget" / "Insufficient balance") now produce an actionable user-facing message instead of the generic "Something went wrong" apology.
  • User-visible effect: Users who enabled a local model but left routing on Managed will see guidance to either top up credits or switch routing to "Use your own model" in Settings → LLM.

Parity Contract

  • Legacy behavior preserved: All other error classifications are unchanged (57/57 existing tests pass).
  • Guard/fallback/dispatch parity checks: The new detection path delegates to the same billing_error::is_budget_exhausted_message used by Sentry-demotion, ensuring consistency.

Duplicate / Superseded PR Handling

  • Duplicate PR(s): None
  • Canonical PR: This one
  • Resolution: N/A

Summary by CodeRabbit

  • Bug Fixes

    • Broader detection of insufficient-credit/budget failures across backend response types, including managed “no credits” cases, so they’re consistently classified as budget exhaustion.
  • User Experience

    • Updated, more actionable out-of-credits message preserving “top up”/“credits” wording and advising users to switch to “Use Your Own Models” via Settings → AI Configuration.
  • Tests

    • Added/extended regression tests to validate classification, source tagging, and messaging for budget-exhaustion scenarios.

…h no credits (tinyhumansai#3088)

The OpenHuman managed backend signals no-credits via a 400 carrying
"Insufficient budget" / "Insufficient balance" (billing_error.rs
canonical phrases).  The web-channel budget detector
(is_inference_budget_exceeded_error) only matched the regex set
("budget.*exceed", "top up", "add.*credits", "out of credits") — it
missed those phrases, so the error fell through to the generic
"Something went wrong" branch.  Users with Ollama enabled but routing
still on Managed saw an opaque provider error and entered an infinite
"Thinking…" state with no way to self-diagnose.

Changes:
- Widen is_inference_budget_exceeded_error to also delegate to
  billing_error::is_budget_exhausted_message (the canonical detector)
  so the 400 "Insufficient budget" path is caught.
- Extend classify_inference_error's budget_exhausted branch to call
  is_inference_budget_exceeded_error as a fallback, catching any
  budget-signal that doesn't carry a 402 status.
- Rewrite the user-facing budget-exceeded copy to guide the user:
  top up credits OR switch routing to "Use your own model" in Settings.
- Add tests for the new detection paths and the updated copy.
@senamakel-droid
senamakel-droid requested a review from a team June 1, 2026 04:23
@coderabbitai

coderabbitai Bot commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ee2baee8-6fa3-4cab-8029-e0e278093c6a

📥 Commits

Reviewing files that changed from the base of the PR and between b8de90a and 9234cc9.

📒 Files selected for processing (2)
  • src/openhuman/channels/providers/web_errors.rs
  • src/openhuman/channels/providers/web_tests.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/openhuman/channels/providers/web_tests.rs
  • src/openhuman/channels/providers/web_errors.rs

📝 Walkthrough

Walkthrough

Budget-exhaustion detection now falls back to the canonical provider detector to catch managed-backend “Insufficient budget/balance” cases; classifier labels these as budget_exhausted/openhuman_billing. The user-facing message was expanded to include top-up guidance and instructions to switch routing to “Use Your Own Models” in Settings → AI Configuration.

Changes

Budget-exhaustion detection and messaging for managed backend

Layer / File(s) Summary
Budget detection robustness and error classification
src/openhuman/channels/providers/web_errors.rs, src/openhuman/channels/providers/web_tests.rs, tests/channels_provider_leftovers_raw_coverage_e2e.rs
is_inference_budget_exceeded_error falls back to provider::is_budget_exhausted_message(message) after regex checks; classify_inference_error includes this detector in its 402/insufficient-balance condition chain and now classifies managed-backend HTTP 400 "Insufficient budget"/"Insufficient balance" as budget_exhausted sourced from openhuman_billing. Tests and E2E expectations updated accordingly.
Actionable error message with local-model guidance
src/openhuman/channels/providers/web_errors.rs, src/openhuman/channels/providers/web_tests.rs
inference_budget_exceeded_user_message expanded to include top-up guidance and a self-diagnosis path for local-model users (e.g., Ollama) directing them to switch routing to "Use Your Own Models" in Settings → AI Configuration. budget_exhausted classified errors now use this function; tests assert presence of "Use Your Own Models" and "Settings" and validate regression classification and message content.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • tinyhumansai/openhuman#2239: Touches the same classify_inference_error function to add deterministic provider error classification (different predicates/branches).
  • tinyhumansai/openhuman#2652: Overlaps in handling budget/billing "no credits/insufficient budget" within inference error classifier and tests.
  • tinyhumansai/openhuman#2809: Related expansion of matching logic for "Insufficient balance"/no-credits style cases.

Suggested reviewers

  • senamakel
  • graycyrus
  • laith-max

Poem

🐰 I sniffed the budget, found it low,
A clearer message now will show,
"Top up" or flip to your own model, true,
Settings → AI Configuration points you through.
Hop on — no more stuck thinking for you!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(inference): surface actionable error when Managed route fails with no credits' directly and clearly describes the main change: improving error messaging when the Managed routing option lacks credits.
Linked Issues check ✅ Passed The PR implements all coding requirements from #3088: detects budget-exhausted errors via canonical billing detection, classifies non-402 budget signals correctly, delivers actionable user-facing messaging guiding users to top up or switch routing, and includes test coverage.
Out of Scope Changes check ✅ Passed All changes are scoped to web-channel error detection and classification logic. No unrelated modifications to UI, frontend, migrations, or other systems are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. working A PR that is being worked on by the team. bug labels Jun 1, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 1, 2026
 fix

The coverage E2E test asserted that "inference budget exceeded:
monthly limit reached" classified as generic `inference`. After
the tinyhumansai#3088 fix widened budget detection, this string correctly
classifies as `budget_exhausted` with source `openhuman_billing`.
Update the assertion to match the new (correct) behavior.
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 1, 2026
oxoxDev
oxoxDev previously approved these changes Jun 1, 2026

@oxoxDev oxoxDev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Tightly-scoped fix: routes the managed-backend 400 "insufficient budget" no-credits failure into the existing budget_exhausted classifier branch with actionable, non-retryable copy (correct — a top-up won't clear on retry). Secret-leak surface is safe — the message reuses with_provider_detail→extract_provider_error_detail→sanitize_api_error+truncate, adds no new raw-err interpolation (same protection class as #3033). Branch ordering is correct: the new is_inference_budget_exceeded_error OR only widens the budget branch and sits below the action-budget/max-iter/429/auth/timeout branches, so none are stolen. Success path untouched.

One wording nit inline. The red CI (Core/Frontend Coverage, E2E lanes 1&4) are coverage-infra + known Playwright flakes — all unit tests pass in the logs.

@sanil-23 sanil-23 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@senamakel-droid the code here looks solid. The fix is well-scoped: widening is_inference_budget_exceeded_error to delegate to the canonical billing_error::is_budget_exhausted_message, plus adding it as a fallback in the budget_exhausted branch of classify_inference_error, is the right way to catch the managed 400 "Insufficient budget" path that was slipping through to the generic error. Reusing the one canonical detector instead of duplicating phrase lists is the correct call, and the new tests cover both the detection paths and the rewritten copy. The user-facing message is a clear improvement for #3088 — it gives both remediation paths without auto-switching.

The blocker is CI, but it looks unrelated to your change:

  • Rust Core Coverage fails on http_models_and_chat_use_mocked_ollama_without_real_runtime (round23 e2e, line 124) — that's an Ollama /models discovery assertion against a mocked HTTP server, nothing this PR touches. Reads as flaky.
  • Frontend Coverage (Vitest) failing on a Rust-only diff with no frontend files changed.
  • E2E lanes 1 & 4 fail while lanes 2 & 3 pass — typical flake pattern.

A re-run should clear these. Once CI is fully green I'll come back and approve — ping me if a re-run doesn't sort it and I'll dig in with you.

Update the user-facing message to reference "Use Your Own Models"
(matching the i18n key settings.ai.routing.useYourOwn) and
"Settings → AI Configuration" (matching settings.ai) instead of
the previous approximation "Use your own model" / "Settings → LLM".
@senamakel-droid
senamakel-droid dismissed stale reviews from oxoxDev and coderabbitai[bot] via 9234cc9 June 1, 2026 15:11
@senamakel-droid

Copy link
Copy Markdown
Contributor Author

Addressed @oxoxDev review nit in 9234cc9 — updated to match the exact i18n labels: "Use Your Own Models" (settings.ai.routing.useYourOwn) and "Settings → AI Configuration" (settings.ai). Good catch, thanks!

@graycyrus graycyrus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@senamakel-droid the code looks good to me. The fix is well-targeted — widening is_inference_budget_exceeded_error to delegate to billing_error::is_budget_exhausted_message closes the gap where "Insufficient budget" 400 responses fell through to the generic branch instead of the budget_exhausted classifier. The updated copy is actionable and the test coverage is solid: new detection tests, copy assertions, the full classification path test, and the e2e regression updated to reflect the corrected classification.

One minor note: the inline comments in web_errors.rs and classify_inference_error that reference #3088 throughout are a bit heavy for production code — they read more like PR rationale than code-level documentation. Fine for now, but worth trimming.

The two CI failures are unrelated to this change:

  • Rust Core Coverage: tool_rule_put_get_list_and_delete_roundtrip failing in memory::ops — completely separate subsystem from web channel error classification.
  • Playwright E2E lane 1: Gmail connector and chat harness tests failing with ECONNREFUSED — nothing in this PR touches those surfaces.

Once the pending checks resolve and the flaky tests clear, I'll come back and approve. let me know if you need anything.

M3gA-Mind added a commit that referenced this pull request Jun 22, 2026
… log empty 2xx streams (#3335) (#3415)

## Summary

- Replace the misleading **"Try a different model or check your local provider in Settings → AI → LLM"** copy on the empty-response classifier — that sent **Managed-route users toward a remedy that doesn't exist for them**, because there is no local provider on the Managed route. The new copy explicitly names the credits / billing remedy alongside the model / provider-config remedies (chat surface), and a tighter ≤120-char form for the cron notification drawer.
- Add a forensic `warn`-level log in `stream_native_chat` for the exact upstream shape that triggers `AgentError::EmptyProviderResponse` — a streaming chat call that returns **HTTP 2xx with zero text, zero thinking, and zero tool calls**. Captures `elapsed_ms`, `sse_chunks_parsed`, `raw_bytes_received`, `has_usage`, `has_openhuman_meta` so production data can disambiguate the three plausible upstream root causes (credit exhaustion served as 200 + empty body, upstream provider stall, real degenerate model output).
- Strictly additive — no behavioural change to request paths, no new Sentry suppression. The diagnostic log fires only on the empty-2xx case (otherwise the existing aggregated log carries the same fields at `info`).

## Problem

Issue #3335 (`iamrhn`): on Managed settings, every chat turn surfaces:

> *"The model returned an empty response. Try a different model or check your local provider in Settings → AI → LLM."*

The "local provider" remedy is nonsensical for Managed users. The Sentry data confirms this is real and broad:

- **TAURI-RUST-4JX**: 2,633 events, 55 distinct clients in 14 days. Releases include current `0.57.13` (PR #2790's Sentry suppression silenced the events but the underlying user-visible bug remains).
- Latest event breadcrumbs (Managed route, `reasoning-v1`, web channel):

  ```
  [provider:OpenHuman] outbound chat/completions -> https://api.tinyhumans.ai/openai/v1/chat/completions
  [stream]   OpenHuman POST … (stream=true, tools=21)
  …~7 s gap, no [stream] error breadcrumb, no [llm_provider] api_error breadcrumb…
  [agent]    provider responded — parsed tool_calls=0 text_chars=0
  [agent_loop] provider returned an empty final response (i=1, no text, no tool calls) — surfacing as error
  ```

  HTTP status was 2xx (no `streaming API error` line), so none of the existing classifiers (`is_budget_exhausted_http_400`, `is_provider_access_policy_denied_http_403`, …) at `compatible.rs:1028-1060` fire. The streaming aggregator builds a `ChatResponse` with `content: None`, the agent loop returns `AgentError::EmptyProviderResponse`, and the chat surface renders the wrong-remediation copy.

This is exactly the shape #3386 (team-filed) identifies as path **(b)**: the OpenHuman managed backend returns 200 + empty SSE under credit exhaustion / upstream stall instead of a typed 400 + `Insufficient budget`. The deeper fix lives in `tinyhumansai/backend`; this PR addresses the **two client-side wins that don't require the server change**.

## Solution

**(1) Copy fix — chat surface (`web_errors.rs::classify_inference_error` empty_response arm).** Replace the `"Try a different model or check your local provider"` text with a three-remedy form that's accurate for **all** providers: *"This usually means your inference credits are exhausted (Settings → Billing), the upstream model is temporarily unhealthy, or your provider configuration is rejecting the request (Settings → AI → LLM). Try one of those, or pick a different model."* New regression test `classify_inference_error_empty_response_copy_names_billing_remedy_and_drops_local_provider_misdirect` locks in: contains `Settings → Billing`, contains `Settings → AI → LLM`, contains `different model`, does NOT contain `local provider`, and `provider` stays `None`. Existing test `classify_inference_error_empty_response_is_actionable_and_retryable` continues to pass.

**(2) Copy fix — cron drawer (`cron/scheduler.rs::agent_error_to_user_message`).** Shorter form for the drawer's ≤120-char convention: *"Empty model response. Out of credits (Settings → Billing) or try a different model in Settings → AI → LLM."* New regression test `agent_error_to_user_message_classifies_empty_provider_response_for_3335` mirrors the chat-side assertions; `EmptyProviderResponse` is added to the existing `agent_error_to_user_message_canned_strings_are_short` variants list to lock in the ≤120-char contract (was previously absent — fit incidentally, but nothing enforced it).

**(3) Diagnostic log — `compatible.rs::stream_native_chat`.** Right after the existing aggregated `info` log, emit a `warn` when `text_accum.is_empty() && thinking_accum.is_empty() && tool_call_count == 0`:

```
[stream] {name} empty 2xx stream — model={..} elapsed_ms={..} sse_chunks={..} raw_bytes={..} has_usage={..} has_openhuman_meta={..}
```

`stream_started_at: Instant`, `sse_chunks_parsed: usize`, and `raw_bytes_received: usize` are added as append-only counters (never read by the request path). The signal disambiguates:

- `elapsed_ms` small + `sse_chunks=0` + `raw_bytes` small → backend closed the SSE near-immediately (credit reject or auth)
- `elapsed_ms` large + `sse_chunks=0` + `raw_bytes=0` → upstream stall / timeout
- `sse_chunks>0` + `has_usage=true` + accumulators empty → backend streamed metadata-only chunks (tokens counted, no content delivered)

**(4) Deliberately deferred — the in-session budget correlator from #3386.** `EmptyProviderResponse`'s flattened Display carries no `" API error"` infix for `extract_provider_name` to anchor on, so the classifier here cannot tell which provider was used without plumbing the typed `provider: Option<String>` through the AgentError. That plumbing affects ~10 string-match test/observability sites and balloons the scope. The diagnostic log is the cheapest path to the production data that decides whether the plumbing is worth doing — once we have evidence on which path is dominant (200+empty under credit exhaustion vs. upstream stall vs. true degenerate output), the next iteration of the empty-response arm can be provider-aware.

## Submission Checklist

> If a section does not apply to this change, mark the item as `N/A` with a one-line reason. Do not delete items.

- [x] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement) — three new tests: `classify_inference_error_empty_response_copy_names_billing_remedy_and_drops_local_provider_misdirect` (web chat copy), `agent_error_to_user_message_classifies_empty_provider_response_for_3335` (cron drawer copy), plus `EmptyProviderResponse` added to `agent_error_to_user_message_canned_strings_are_short` variants (locks in the ≤120-char contract that was previously unenforced).
- [x] **Diff coverage ≥ 80%** — every new/changed line in `web_errors.rs` and `cron/scheduler.rs` is exercised by the three new regression tests; the `compatible.rs` diagnostic log is observability-only (no behavioural branching) and covered by the existing streaming-chat test surface that drives it. Run `pnpm test:rust` locally to verify.
- [x] Coverage matrix updated — `N/A: behaviour-only change` (error-message copy + observability log, no new feature ID).
- [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` — `N/A: behaviour-only change, no matrix rows touched`.
- [x] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy)) — `N/A: tests are pure unit-level over the classifier and the AgentError variant; no provider HTTP exercised`.
- [x] Manual smoke checklist updated if this touches release-cut surfaces ([`docs/RELEASE-MANUAL-SMOKE.md`](../docs/RELEASE-MANUAL-SMOKE.md)) — `N/A: error-copy + log only, no UI flow or release-cut surface affected`.
- [x] Linked issue closed via `Closes #NNN` in the `## Related` section.

## Impact

- **Desktop (chat surface)**: Managed-route users hitting empty-response failures now see an actionable, non-misleading message that names `Settings → Billing` as the most likely fix (per Sentry / #3386 evidence) without losing the existing `Settings → AI → LLM` and model-switch remedies. Self-hosted users see the same three-remedy framing — strictly more accurate than the prior "local provider" claim for users on cloud providers.
- **Desktop (cron notifications)**: Same behavioural change, shorter copy to fit the ≤120-char drawer-render budget. Now enforced by the existing length test.
- **Observability**: Net +1 `warn` log per streaming chat turn that returns 2xx-with-empty-body. This is the failure case being investigated; non-empty streams are unaffected. Lands in Sentry breadcrumbs even after `AgentError::skips_sentry()` (#2790) silences the parent event, unblocking the data needed to decide whether the in-session budget correlator from #3386 is the right next move or whether the fix has to be server-side.
- **Performance / security**: No request-path behaviour change. The added counters are stack-local `usize` increments. The diagnostic log redacts nothing new — provider name, model name, and counts are all already in the existing aggregated `info` log; only the byte total and elapsed ms are net-new and both are content-free.
- **Migration / compatibility**: None. Pure string + observability change.

## Related

- Closes: #3335
- Related: #3386 (the team-filed deep-dive that this PR addresses without requiring the per-session budget correlator), #3104 (cascade-error reports likely sharing the same empty-stream root cause), #2790 (the Sentry suppression that silenced TAURI-RUST-4JX without fixing the user-visible bug), #3199 (the empty-response arm being modified), #3121 (the budget-exhausted copy whose remedy this copy now points users toward), #3092 (closed parent issue for the chat-error cluster).
- Follow-up PR(s)/TODOs: Once the diagnostic log produces 24–48 h of production data, decide between (a) opening a backend issue against `tinyhumansai/backend` to start returning `400 + Insufficient budget` on streaming under credit exhaustion (the right server-side fix), and (b) plumbing `provider: Option<String>` through `AgentError::EmptyProviderResponse` so the classifier can be provider-aware (the #3386 in-session correlator approach).

---

## AI Authored PR Metadata (required for Codex/Linear PRs)

> Keep this section for AI-authored PRs. For human-only PRs, mark each field `N/A`.

### Linear Issue

- Key: N/A — GitHub issue #3335, not Linear-tracked.
- URL: N/A

### Commit & Branch

- Branch: `fix/3335-managed-empty-response-diagnostic-and-copy`
- Commit SHA: `7064cbfee`

### Validation Run

- [x] `pnpm --filter openhuman-app format:check` — `N/A: no frontend / TS files changed`
- [x] `pnpm typecheck` — `N/A: no frontend / TS files changed`
- [x] Focused tests: `cargo test --lib empty_response` (10 pass), `cargo test --lib empty_provider` (5 pass), `cargo test --lib cron::scheduler::tests::agent_error` (11 pass), `cargo test --lib compatible::tests` (153 pass).
- [x] Rust fmt/check (if changed): `cargo fmt --manifest-path Cargo.toml` + `cargo check --manifest-path Cargo.toml --lib` — both clean (only pre-existing unrelated warnings).
- [x] Tauri fmt/check (if changed): `N/A: no app/src-tauri files changed`

### Validation Blocked

- `command:` N/A
- `error:` N/A
- `impact:` N/A

### Behavior Changes

- Intended behavior change: User-facing error copy for `AgentError::EmptyProviderResponse` no longer claims a "local provider" exists; new copy names `Settings → Billing` (credits) alongside `Settings → AI → LLM` (provider config) and the model-switch fallback.
- User-visible effect: Managed-route users who hit empty-response failures now receive remedy text that matches the most likely actual cause (credit exhaustion, per #3386 evidence). Self-hosted users see the same three-remedy framing, which remains accurate for their configurations.

### Parity Contract

- Legacy behavior preserved: `AgentError::EmptyProviderResponse` Display, `classify_inference_error` error_type / source / retryable / provider fields, `agent_error_to_user_message` dispatch matrix, the `Settings → AI → LLM` deep-link reference. Existing test `classify_inference_error_empty_response_is_actionable_and_retryable` continues to pass unchanged.
- Guard/fallback/dispatch parity checks: The empty-response arm in `web_errors.rs` keeps the same `error_type: "empty_response"`, `source: "agent_loop"`, `retryable: true`, `retry_after_ms: None`, `provider: None`, `fallback_available: None` — only the message string changes. The cron `agent_error_to_user_message` dispatch matrix is unchanged; only the `EmptyProviderResponse` branch's returned string changes.

### Duplicate / Superseded PR Handling

- Duplicate PR(s): None — no other open PR references #3335.
- Canonical PR: This one.
- Resolution: N/A (no prior PR to supersede).


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **Bug Fixes**
  * Improved error messaging when model returns an empty response, now guiding users to check inference credits (Settings → Billing), try a different model, or verify LLM provider configuration (Settings → AI → LLM).

* **Improvements**
  * Added enhanced diagnostic logging for empty response scenarios to better support troubleshooting.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Closes #3335

Co-authored-by: M3gA-Mind <megamind@mahadao.com>
senamakel pushed a commit to nocstah/openhuman that referenced this pull request Sep 11, 2026
senamakel pushed a commit to nocstah/openhuman that referenced this pull request Sep 11, 2026
… log empty 2xx streams (tinyhumansai#3335) (tinyhumansai#3415)\n\n## Summary\n\n- Replace the misleading **"Try a different model or check your local provider in Settings → AI → LLM"** copy on the empty-response classifier — that sent **Managed-route users toward a remedy that doesn't exist for them**, because there is no local provider on the Managed route. The new copy explicitly names the credits / billing remedy alongside the model / provider-config remedies (chat surface), and a tighter ≤120-char form for the cron notification drawer.\n- Add a forensic `warn`-level log in `stream_native_chat` for the exact upstream shape that triggers `AgentError::EmptyProviderResponse` — a streaming chat call that returns **HTTP 2xx with zero text, zero thinking, and zero tool calls**. Captures `elapsed_ms`, `sse_chunks_parsed`, `raw_bytes_received`, `has_usage`, `has_openhuman_meta` so production data can disambiguate the three plausible upstream root causes (credit exhaustion served as 200 + empty body, upstream provider stall, real degenerate model output).\n- Strictly additive — no behavioural change to request paths, no new Sentry suppression. The diagnostic log fires only on the empty-2xx case (otherwise the existing aggregated log carries the same fields at `info`).\n\n## Problem\n\nIssue tinyhumansai#3335 (`iamrhn`): on Managed settings, every chat turn surfaces:\n\n> *"The model returned an empty response. Try a different model or check your local provider in Settings → AI → LLM."*\n\nThe "local provider" remedy is nonsensical for Managed users. The Sentry data confirms this is real and broad:\n\n- **TAURI-RUST-4JX**: 2,633 events, 55 distinct clients in 14 days. Releases include current `0.57.13` (PR tinyhumansai#2790's Sentry suppression silenced the events but the underlying user-visible bug remains).\n- Latest event breadcrumbs (Managed route, `reasoning-v1`, web channel):\n\n  ```\n  [provider:OpenHuman] outbound chat/completions -> https://api.tinyhumans.ai/openai/v1/chat/completions\n  [stream]   OpenHuman POST … (stream=true, tools=21)\n  …~7 s gap, no [stream] error breadcrumb, no [llm_provider] api_error breadcrumb…\n  [agent]    provider responded — parsed tool_calls=0 text_chars=0\n  [agent_loop] provider returned an empty final response (i=1, no text, no tool calls) — surfacing as error\n  ```\n\n  HTTP status was 2xx (no `streaming API error` line), so none of the existing classifiers (`is_budget_exhausted_http_400`, `is_provider_access_policy_denied_http_403`, …) at `compatible.rs:1028-1060` fire. The streaming aggregator builds a `ChatResponse` with `content: None`, the agent loop returns `AgentError::EmptyProviderResponse`, and the chat surface renders the wrong-remediation copy.\n\nThis is exactly the shape tinyhumansai#3386 (team-filed) identifies as path **(b)**: the OpenHuman managed backend returns 200 + empty SSE under credit exhaustion / upstream stall instead of a typed 400 + `Insufficient budget`. The deeper fix lives in `tinyhumansai/backend`; this PR addresses the **two client-side wins that don't require the server change**.\n\n## Solution\n\n**(1) Copy fix — chat surface (`web_errors.rs::classify_inference_error` empty_response arm).** Replace the `"Try a different model or check your local provider"` text with a three-remedy form that's accurate for **all** providers: *"This usually means your inference credits are exhausted (Settings → Billing), the upstream model is temporarily unhealthy, or your provider configuration is rejecting the request (Settings → AI → LLM). Try one of those, or pick a different model."* New regression test `classify_inference_error_empty_response_copy_names_billing_remedy_and_drops_local_provider_misdirect` locks in: contains `Settings → Billing`, contains `Settings → AI → LLM`, contains `different model`, does NOT contain `local provider`, and `provider` stays `None`. Existing test `classify_inference_error_empty_response_is_actionable_and_retryable` continues to pass.\n\n**(2) Copy fix — cron drawer (`cron/scheduler.rs::agent_error_to_user_message`).** Shorter form for the drawer's ≤120-char convention: *"Empty model response. Out of credits (Settings → Billing) or try a different model in Settings → AI → LLM."* New regression test `agent_error_to_user_message_classifies_empty_provider_response_for_3335` mirrors the chat-side assertions; `EmptyProviderResponse` is added to the existing `agent_error_to_user_message_canned_strings_are_short` variants list to lock in the ≤120-char contract (was previously absent — fit incidentally, but nothing enforced it).\n\n**(3) Diagnostic log — `compatible.rs::stream_native_chat`.** Right after the existing aggregated `info` log, emit a `warn` when `text_accum.is_empty() && thinking_accum.is_empty() && tool_call_count == 0`:\n\n```\n[stream] {name} empty 2xx stream — model={..} elapsed_ms={..} sse_chunks={..} raw_bytes={..} has_usage={..} has_openhuman_meta={..}\n```\n\n`stream_started_at: Instant`, `sse_chunks_parsed: usize`, and `raw_bytes_received: usize` are added as append-only counters (never read by the request path). The signal disambiguates:\n\n- `elapsed_ms` small + `sse_chunks=0` + `raw_bytes` small → backend closed the SSE near-immediately (credit reject or auth)\n- `elapsed_ms` large + `sse_chunks=0` + `raw_bytes=0` → upstream stall / timeout\n- `sse_chunks>0` + `has_usage=true` + accumulators empty → backend streamed metadata-only chunks (tokens counted, no content delivered)\n\n**(4) Deliberately deferred — the in-session budget correlator from tinyhumansai#3386.** `EmptyProviderResponse`'s flattened Display carries no `" API error"` infix for `extract_provider_name` to anchor on, so the classifier here cannot tell which provider was used without plumbing the typed `provider: Option<String>` through the AgentError. That plumbing affects ~10 string-match test/observability sites and balloons the scope. The diagnostic log is the cheapest path to the production data that decides whether the plumbing is worth doing — once we have evidence on which path is dominant (200+empty under credit exhaustion vs. upstream stall vs. true degenerate output), the next iteration of the empty-response arm can be provider-aware.\n\n## Submission Checklist\n\n> If a section does not apply to this change, mark the item as `N/A` with a one-line reason. Do not delete items.\n\n- [x] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement) — three new tests: `classify_inference_error_empty_response_copy_names_billing_remedy_and_drops_local_provider_misdirect` (web chat copy), `agent_error_to_user_message_classifies_empty_provider_response_for_3335` (cron drawer copy), plus `EmptyProviderResponse` added to `agent_error_to_user_message_canned_strings_are_short` variants (locks in the ≤120-char contract that was previously unenforced).\n- [x] **Diff coverage ≥ 80%** — every new/changed line in `web_errors.rs` and `cron/scheduler.rs` is exercised by the three new regression tests; the `compatible.rs` diagnostic log is observability-only (no behavioural branching) and covered by the existing streaming-chat test surface that drives it. Run `pnpm test:rust` locally to verify.\n- [x] Coverage matrix updated — `N/A: behaviour-only change` (error-message copy + observability log, no new feature ID).\n- [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` — `N/A: behaviour-only change, no matrix rows touched`.\n- [x] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy)) — `N/A: tests are pure unit-level over the classifier and the AgentError variant; no provider HTTP exercised`.\n- [x] Manual smoke checklist updated if this touches release-cut surfaces ([`docs/RELEASE-MANUAL-SMOKE.md`](../docs/RELEASE-MANUAL-SMOKE.md)) — `N/A: error-copy + log only, no UI flow or release-cut surface affected`.\n- [x] Linked issue closed via `Closes #NNN` in the `## Related` section.\n\n## Impact\n\n- **Desktop (chat surface)**: Managed-route users hitting empty-response failures now see an actionable, non-misleading message that names `Settings → Billing` as the most likely fix (per Sentry / tinyhumansai#3386 evidence) without losing the existing `Settings → AI → LLM` and model-switch remedies. Self-hosted users see the same three-remedy framing — strictly more accurate than the prior "local provider" claim for users on cloud providers.\n- **Desktop (cron notifications)**: Same behavioural change, shorter copy to fit the ≤120-char drawer-render budget. Now enforced by the existing length test.\n- **Observability**: Net +1 `warn` log per streaming chat turn that returns 2xx-with-empty-body. This is the failure case being investigated; non-empty streams are unaffected. Lands in Sentry breadcrumbs even after `AgentError::skips_sentry()` (tinyhumansai#2790) silences the parent event, unblocking the data needed to decide whether the in-session budget correlator from tinyhumansai#3386 is the right next move or whether the fix has to be server-side.\n- **Performance / security**: No request-path behaviour change. The added counters are stack-local `usize` increments. The diagnostic log redacts nothing new — provider name, model name, and counts are all already in the existing aggregated `info` log; only the byte total and elapsed ms are net-new and both are content-free.\n- **Migration / compatibility**: None. Pure string + observability change.\n\n## Related\n\n- Closes: tinyhumansai#3335\n- Related: tinyhumansai#3386 (the team-filed deep-dive that this PR addresses without requiring the per-session budget correlator), tinyhumansai#3104 (cascade-error reports likely sharing the same empty-stream root cause), tinyhumansai#2790 (the Sentry suppression that silenced TAURI-RUST-4JX without fixing the user-visible bug), tinyhumansai#3199 (the empty-response arm being modified), tinyhumansai#3121 (the budget-exhausted copy whose remedy this copy now points users toward), tinyhumansai#3092 (closed parent issue for the chat-error cluster).\n- Follow-up PR(s)/TODOs: Once the diagnostic log produces 24–48 h of production data, decide between (a) opening a backend issue against `tinyhumansai/backend` to start returning `400 + Insufficient budget` on streaming under credit exhaustion (the right server-side fix), and (b) plumbing `provider: Option<String>` through `AgentError::EmptyProviderResponse` so the classifier can be provider-aware (the tinyhumansai#3386 in-session correlator approach).\n\n---\n\n## AI Authored PR Metadata (required for Codex/Linear PRs)\n\n> Keep this section for AI-authored PRs. For human-only PRs, mark each field `N/A`.\n\n### Linear Issue\n\n- Key: N/A — GitHub issue tinyhumansai#3335, not Linear-tracked.\n- URL: N/A\n\n### Commit & Branch\n\n- Branch: `fix/3335-managed-empty-response-diagnostic-and-copy`\n- Commit SHA: `7064cbfee`\n\n### Validation Run\n\n- [x] `pnpm --filter openhuman-app format:check` — `N/A: no frontend / TS files changed`\n- [x] `pnpm typecheck` — `N/A: no frontend / TS files changed`\n- [x] Focused tests: `cargo test --lib empty_response` (10 pass), `cargo test --lib empty_provider` (5 pass), `cargo test --lib cron::scheduler::tests::agent_error` (11 pass), `cargo test --lib compatible::tests` (153 pass).\n- [x] Rust fmt/check (if changed): `cargo fmt --manifest-path Cargo.toml` + `cargo check --manifest-path Cargo.toml --lib` — both clean (only pre-existing unrelated warnings).\n- [x] Tauri fmt/check (if changed): `N/A: no app/src-tauri files changed`\n\n### Validation Blocked\n\n- `command:` N/A\n- `error:` N/A\n- `impact:` N/A\n\n### Behavior Changes\n\n- Intended behavior change: User-facing error copy for `AgentError::EmptyProviderResponse` no longer claims a "local provider" exists; new copy names `Settings → Billing` (credits) alongside `Settings → AI → LLM` (provider config) and the model-switch fallback.\n- User-visible effect: Managed-route users who hit empty-response failures now receive remedy text that matches the most likely actual cause (credit exhaustion, per tinyhumansai#3386 evidence). Self-hosted users see the same three-remedy framing, which remains accurate for their configurations.\n\n### Parity Contract\n\n- Legacy behavior preserved: `AgentError::EmptyProviderResponse` Display, `classify_inference_error` error_type / source / retryable / provider fields, `agent_error_to_user_message` dispatch matrix, the `Settings → AI → LLM` deep-link reference. Existing test `classify_inference_error_empty_response_is_actionable_and_retryable` continues to pass unchanged.\n- Guard/fallback/dispatch parity checks: The empty-response arm in `web_errors.rs` keeps the same `error_type: "empty_response"`, `source: "agent_loop"`, `retryable: true`, `retry_after_ms: None`, `provider: None`, `fallback_available: None` — only the message string changes. The cron `agent_error_to_user_message` dispatch matrix is unchanged; only the `EmptyProviderResponse` branch's returned string changes.\n\n### Duplicate / Superseded PR Handling\n\n- Duplicate PR(s): None — no other open PR references tinyhumansai#3335.\n- Canonical PR: This one.\n- Resolution: N/A (no prior PR to supersede).\n\n\n<!-- This is an auto-generated comment: release notes by coderabbit.ai -->\n\n## Summary by CodeRabbit\n\n## Release Notes\n\n* **Bug Fixes**\n  * Improved error messaging when model returns an empty response, now guiding users to check inference credits (Settings → Billing), try a different model, or verify LLM provider configuration (Settings → AI → LLM).\n\n* **Improvements**\n  * Added enhanced diagnostic logging for empty response scenarios to better support troubleshooting.\n\n<!-- end of auto-generated comment: release notes by coderabbit.ai -->\n\nCloses tinyhumansai#3335\n\n
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. working A PR that is being worked on by the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ollama enabled but routing on Managed — provider error + infinite thinking loop on 2 machines (no credits)

5 participants