fix(observability): Wave 4 classifier — socket transport + custom-provider config-rejection (~366 events, 13 IDs)

## Summary

- Extends `expected_error_kind` matcher ladder with 11 new substring arms across two existing buckets — no new variants, no new emit sites, no behavior change beyond reclassifying known wire shapes that were leaking past Wave 1-3 anchors.
- Closes 13 Sentry IDs (~366 events) — **all deterministic user-environment / user-config errors with no remediation path** (offline, DNS fail, captive portal, bad API key, out of credits, wrong model id, missing OAuth scope, region block).
- Pure classifier — no UI, retry, or fallback logic touched. The reliable-provider stack still falls back to OpenHuman's hosted tier for the Lane O bodies; users keep their chat. Lane N socket failures already gated through `report_error_or_expected` at `ws_loop.rs:191`, so the threshold-escalation event simply demotes to a `warn` breadcrumb instead of paging.
- Two micro-commits — Lane O (`is_provider_config_rejection_message` PHRASES const) and Lane N (`is_network_unreachable_message` substring arms) — each independently revertible.

## Problem

After the Wave 1-3 sweep landed, fresh Sentry triage surfaced **13 unresolved IDs** whose bodies match the *spirit* of an existing classifier bucket but use wire-shape variants the current substring anchors miss:

### Lane O — custom-provider config rejection (~250 events)

The `ProviderConfigRejection` variant + `is_provider_config_rejection_message` exist precisely for "user pointed OpenHuman at a custom_openai endpoint with a model / temperature / region / credential that provider doesn't accept." But the 7 phrases shipped in Wave 1-3 (#2079 / #2076 / #2202) only cover the DeepSeek / OpenRouter abstract-tier-leak and Moonshot temperature shapes. New surfaces:

| Sentry ID | Events | Real wire body |
|---|---|---|
| `R1` | 44 | `{"error":{"message":"This model is not available in your region.","code":403}}` |
| `R4` | 14 | `{"code":403,"reason":"ModelNotAllowed","message":"模型不允许访问"…}` (Doubao/ChatGLM) |
| `YC` | 16 | `{"error":{"type":"invalid_authentication_error"…}}` |
| `S5` | 14 | `{"error":{"message":"This request requires more credits, or fewer max_tokens…"}}` (OpenRouter 402) |
| `Y0` | 13 | `{'error': '/chat/completions: Invalid model name passed in model=reasoning-v1…'}` |
| `JN` | 14 | `{"error":{"message":"No active credentials for provider: openai"…}}` |
| `KB` | 16 | Same `No active credentials for provider` shape from OpenHuman backend re-emit |
| `JK` | 17 | `litellm.BadRequestError: Github_copilotException - Bad Request…` |
| `J2` / `J5` / `J4` | 62 | `{"error":{"message":"model 'llama3.3' not found","type":"not_found_error"…}}` |

### Lane N — socket WebSocket-connect transport (~116 events)

`is_network_unreachable_message` already catches `connection refused` / `dns error` / `network is unreachable` but two real shapes escape:

| Sentry ID | Events | Real wire body |
|---|---|---|
| `44` | 50 | `[socket] Connection failed: WebSocket connect: IO error: failed to lookup address information: nodename nor servname provided, or not known` |
| `4P` | 66 | `[socket] Connection failed: WebSocket connect: HTTP error: 200 OK` (captive portal / corporate proxy intercepting the WS upgrade handshake) |

Every one of these is deterministic **user-environment** / **user-configuration** state — the maintainers have no remediation. Sentry has no signal to act on. Every event was pure noise.

## Solution

### Lane O — `src/openhuman/inference/provider/config_rejection.rs:55-79`

Append 8 new phrases to the `PHRASES` const (case-insensitive substring match, same precedence as existing 7):

```rust
"not available in your region",        // R1
"modelnotallowed",                     // R4
"invalid_authentication_error",        // YC
"requires more credits",               // S5
"invalid model name passed in model=", // Y0
"no active credentials for provider",  // JN + KB
"litellm.badrequesterror",             // JK
"not_found_error",                     // J2 + J5 + J4
```

Each anchor is intentionally narrow (e.g. `passed in model=` not bare `invalid model name`; `litellm.badrequesterror` not bare `litellm`) so a stray log line elsewhere can't accidentally demote a real provider/backend bug. The HTTP-layer wrapper (`is_provider_config_rejection_http`) still guards on `provider != openhuman_backend::PROVIDER_LABEL`, so a model rejection from **our own** backend (which would be a real regression) still reaches Sentry.

### Lane N — `src/core/observability.rs:299-319`

Three new substring arms appended to `is_network_unreachable_message`:

- `failed to lookup address` + `nodename nor servname` — libc `getaddrinfo()` failure renderings on macOS / BSD / POSIX resolvers when tungstenite wraps as `IO error` without the reqwest `dns error` prefix.
- `http error: 200 ok` — tungstenite-only render. Reqwest renders HTTP 200 as `"HTTP status server error (200)"`, so no collision with the regular HTTP call path. A negative precedence test (`http_200_classifier_does_not_silence_unrelated_log_lines`) pins this against benign `HTTP/1.1 200 OK` / `status: 200 OK` prose so a future broadening cannot silence success traces.

### Design trade-off — classifier vs. root-fix validation layer

The Lane O bugs *do* have a more durable root fix: a pre-save provider validation layer (test the API key, validate the model id against `/v1/models`, surface region-blocks at config-save time). That's a real product initiative requiring UX design, per-provider model-list infrastructure, and meaningful spec work — out of scope for a triage sweep. This PR follows the Wave 1-3 maintainer precedent (classifier-first noise suppression) so the Sentry signal/noise ratio improves immediately; the validation layer remains tracked as a separate follow-up. If/when it lands, these classifier arms become redundant belt-and-suspenders and can be deleted without conflict.

Lane N has no root-fix alternative — offline / firewall / captive-portal / DNS failures are pure user-environment state. Classifier-demote is the correct disposition.

## 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)
- [x] **Diff coverage ≥ 80%** — changed lines (Vitest + cargo-llvm-cov merged via `diff-cover`) meet the gate enforced by [`.github/workflows/coverage.yml`](../.github/workflows/coverage.yml). 11 positive tests pinned to real Sentry bodies + 1 negative precedence test cover every new substring arm; only the rustdoc comments and the negative `unrelated_*` test body are non-executable lines.
- [x] N/A: behaviour-only change — Coverage matrix updated — added/removed/renamed feature rows in [`docs/TEST-COVERAGE-MATRIX.md`](../docs/TEST-COVERAGE-MATRIX.md) reflect this change
- [x] All affected feature IDs from the matrix are listed in the PR description under `## Related`
- [x] No new external network dependencies introduced (mock backend used per [Testing Strategy](../gitbooks/developing/testing-strategy.md#mock-policy))
- [x] N/A: classifier-only change with no UI / release-cut surface touched — Manual smoke checklist updated if this touches release-cut surfaces ([`docs/RELEASE-MANUAL-SMOKE.md`](../docs/RELEASE-MANUAL-SMOKE.md))
- [x] Linked issue closed via `Closes #NNN` in the `## Related` section

## Impact

- **Runtime**: no behavior change for users — chat / agent / web-channel / socket paths continue to fall back / retry exactly as before. The only delta is that `report_error_or_expected` now classifies these 13 wire shapes as `ExpectedErrorKind::*` instead of escalating as `tracing::error`, so Sentry stops receiving the events.
- **Performance**: negligible — `is_provider_config_rejection_message` lowercases once and runs 15 substring scans (was 7); `is_network_unreachable_message` runs 11 substring scans (was 8). Both already on the error-path, never on the hot path.
- **Security**: no new attack surface. Substring matchers run on already-rendered error messages — no parsing, no allocation beyond the existing `to_ascii_lowercase()`.
- **Migration**: none.
- **Compatibility**: forward-compatible. New phrases are append-only — no existing matcher behavior changes. If a future maintainer ships the root-fix validation layer, deleting these arms is a clean local edit.

## Related

- Closes OPENHUMAN-TAURI-R1
- Closes OPENHUMAN-TAURI-R4
- Closes OPENHUMAN-TAURI-YC
- Closes OPENHUMAN-TAURI-S5
- Closes OPENHUMAN-TAURI-Y0
- Closes OPENHUMAN-TAURI-JN
- Closes OPENHUMAN-TAURI-KB
- Closes OPENHUMAN-TAURI-JK
- Closes OPENHUMAN-TAURI-J2
- Closes OPENHUMAN-TAURI-J5
- Closes OPENHUMAN-TAURI-J4
- Closes OPENHUMAN-TAURI-44
- Closes OPENHUMAN-TAURI-4P
- Follow-up PR(s)/TODOs: pre-save provider-validation layer (validate API key + model id + region at config-save time) tracked as separate product initiative; would supersede the Lane O arms.

---

## 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 (Sentry-driven triage; no Linear ticket)
- URL: N/A

### Commit & Branch
- Branch: fix/sentry-wave4-classifier-no
- Commit SHA: e16414ac (tip)

### Agent
- Claude Code (Sonnet 4.6) running locally

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

## Summary by CodeRabbit

* **Bug Fixes**
  * Enhanced detection of network connectivity failures and provider configuration rejection scenarios for more accurate error classification.

* **Tests**
  * Added comprehensive test coverage for network-related transport failures and provider configuration rejection cases.

<!-- review_stack_entry_start -->

[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2309?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

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

Co-authored-by: oxoxDev <nikhil@tinyhumans.ai>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-05-20 16:54:07 -07:00
committed by GitHub
co-authored by oxoxDev Steven Enamakel
parent ff918f030d
commit f7dfa94e5d
2 changed files with 191 additions and 6 deletions
+64
View File
@@ -289,15 +289,34 @@ fn is_loopback_unavailable(lower: &str) -> bool {
/// through [`is_loopback_unavailable`] *before* this matcher so the
/// boot-window race against the embedded core keeps its own bucket — see
/// the precedence comment in [`expected_error_kind`].
///
/// Three additional substrings cover wire-shape variants observed in
/// Wave 4 that the original `"dns error"` / status-code matchers miss:
///
/// - `"failed to lookup address"` / `"nodename nor servname"` —
/// `getaddrinfo()` failure renderings on macOS / BSD libc and POSIX
/// resolvers (`OPENHUMAN-TAURI-44` ~50 events,
/// `[socket] Connection failed: WebSocket connect: IO error: failed to
/// lookup address information: nodename nor servname provided, or not
/// known`).
/// - `"http error: 200 ok"` — tungstenite's `WsError::Http(200)` render
/// when a corporate proxy / captive portal intercepts the WebSocket
/// handshake and returns a plain HTML 200 page (`OPENHUMAN-TAURI-4P`
/// ~66 events). Tungstenite-only — reqwest renders HTTP 200 as
/// `"HTTP status server error (200)"`, so this can't collide with the
/// regular HTTP call path.
fn is_network_unreachable_message(lower: &str) -> bool {
lower.contains("error sending request for url")
|| lower.contains("dns error")
|| lower.contains("failed to lookup address")
|| lower.contains("nodename nor servname")
|| lower.contains("connection refused")
|| lower.contains("connection reset")
|| lower.contains("network is unreachable")
|| lower.contains("no route to host")
|| lower.contains("tls handshake")
|| lower.contains("certificate verify failed")
|| lower.contains("http error: 200 ok")
}
/// Detect transient upstream HTTP failures that have bubbled up out of the
@@ -1278,6 +1297,51 @@ mod tests {
);
}
#[test]
fn classifies_wave4_socket_transport_wire_shapes() {
// OPENHUMAN-TAURI-44 (~50 events): libc `getaddrinfo()` rendering
// without the `dns error` token, wrapped by the socket emit site.
// The Wave 4 matcher arms catch the literal resolver phrases that
// the original `dns error` substring would miss when reqwest's
// wrapper isn't in the chain (e.g. tungstenite IO errors).
assert_eq!(
expected_error_kind(
"[socket] Connection failed (sustained outage after 5 attempts): \
WebSocket connect: IO error: failed to lookup address information: \
nodename nor servname provided, or not known"
),
Some(ExpectedErrorKind::NetworkUnreachable)
);
// OPENHUMAN-TAURI-4P (~66 events): tungstenite renders a captive
// portal / corporate proxy that intercepts the WS handshake as
// `WsError::Http(200)` → `"HTTP error: 200 OK"`. Classify as
// network-unreachable since no amount of app-side retry can pierce
// an intercepting proxy.
assert_eq!(
expected_error_kind(
"[socket] Connection failed (sustained outage after 5 attempts): \
WebSocket connect: HTTP error: 200 OK"
),
Some(ExpectedErrorKind::NetworkUnreachable)
);
}
#[test]
fn http_200_classifier_does_not_silence_unrelated_log_lines() {
// The captive-portal arm anchors on `"http error: 200 ok"` (the
// exact tungstenite `WsError::Http(200)` Display rendering).
// Adjacent non-WebSocket log lines that mention `"HTTP/1.1 200 OK"`
// or `"status: 200 OK"` MUST NOT classify — those are normal-flow
// success traces, not failure events. Pin this precedence so a
// future refactor doesn't broaden the substring.
assert_eq!(expected_error_kind("HTTP/1.1 200 OK"), None);
assert_eq!(
expected_error_kind("upstream returned status: 200 OK after retry"),
None
);
}
#[test]
fn classifies_transient_upstream_http_errors() {
// OPENHUMAN-TAURI-5Z: the canonical shape emitted by
@@ -15,15 +15,32 @@
//! Moonshot Kimi K2)
//! - `"The model \`gpt-5.5\` does not exist or you do not have access to
//! it."` / `"model_not_found"` (stale model pin)
//! - `"This model is not available in your region."` (R1 — region-blocked
//! model on a custom cloud provider)
//! - `"ModelNotAllowed"` (R4 — Doubao/ChatGLM model-allowlist enforcement)
//! - `"invalid_authentication_error"` (YC — user pasted a malformed /
//! revoked API key into the provider config)
//! - `"This request requires more credits"` (S5 — OpenRouter `402` when
//! the user's account is out of credits)
//! - `"Invalid model name passed in model="` (Y0 — litellm-style proxy
//! rejecting a model id pre-routing)
//! - `"No active credentials for provider:"` (JN / KB — user hasn't
//! plugged in their API key for the selected provider yet)
//! - `"litellm.BadRequestError"` (JK — litellm github_copilot proxy 400
//! from a user OAuth/scope gap)
//! - `"not_found_error"` (J2 / J5 / J4 — litellm-compatible envelope
//! `type` field carrying "model 'X' not found")
//!
//! These are **deterministic user-configuration state**, not bugs the
//! maintainers can act on: the user pointed OpenHuman at a custom
//! provider with a model / temperature that provider does not accept. The
//! remediation is "fix the model or routing in Settings", which the UI
//! surfaces. Yet every agent turn produces a fresh Sentry event
//! (OPENHUMAN-TAURI-WJ / -QW / -HB / -NH — 88 + 146 + 39 events). This is
//! the same class as budget-exhaustion ([`super::billing_error`]) and
//! must be demoted from Sentry to an info log the same way.
//! provider with a model / temperature / region / credential that
//! provider does not accept. The remediation is "fix the model, key, or
//! routing in Settings", which the UI surfaces. Yet every agent turn
//! produces a fresh Sentry event (OPENHUMAN-TAURI-WJ / -QW / -HB / -NH /
//! -R1 / -R4 / -YC / -S5 / -Y0 / -JN / -KB / -JK / -J2 / -J5 / -J4 —
//! ~250 additional events on top of the Wave 1-3 IDs). This is the
//! same class as budget-exhaustion ([`super::billing_error`]) and must
//! be demoted from Sentry to an info log the same way.
//!
//! ## Provider-aware polarity (important)
//!
@@ -72,6 +89,53 @@ pub fn is_provider_config_rejection_message(body: &str) -> bool {
// Our own actionable error once a proper tier→model resolution
// is in place (keeps this classifier stable across that fix).
"is an abstract tier",
// OPENHUMAN-TAURI-R1 — custom_openai upstream 403 with body
// `{"error":{"message":"This model is not available in your region.","code":403}}`.
// User picked a model the provider blocks for their account's
// region. Sentry has no remediation; user must switch model.
"not available in your region",
// OPENHUMAN-TAURI-R4 — Doubao / ChatGLM-style model allowlist
// enforcement. Body: `{"reason":"ModelNotAllowed",...}`. Match
// lowercased — the provider sends the camelCase token as a
// sentinel `reason` value.
"modelnotallowed",
// OPENHUMAN-TAURI-YC — user-supplied custom_openai API key was
// rejected by upstream with the OpenAI-compatible
// `{"error":{"type":"invalid_authentication_error",...}}`
// envelope. Anchored on the type token (stable across providers
// that emit this OpenAI-compatible body).
"invalid_authentication_error",
// OPENHUMAN-TAURI-S5 — OpenRouter 402 when the user is out of
// credits. Body always carries "requires more credits, or fewer
// max_tokens"; pin to the unique-enough credits phrase. (The
// separate `billing_error` classifier handles our own
// OpenHuman-backend balance gate; this catches the third-party
// OpenRouter shape that re-emits via `agent.run_single`.)
"requires more credits",
// OPENHUMAN-TAURI-Y0 — litellm-style proxy rejected the model
// id pre-routing with `Invalid model name passed in model=…`.
// Anchored on the `passed in model=` suffix so a stray "invalid
// model name" log line elsewhere does not classify.
"invalid model name passed in model=",
// OPENHUMAN-TAURI-JN / -KB — custom provider proxy that fronts
// multiple upstream APIs surfaces a "you haven't configured the
// upstream provider yet" 401/404 as `{"error":{"message":"No
// active credentials for provider: openai",...}}`. The
// remediation is "add the upstream API key in Settings".
"no active credentials for provider",
// OPENHUMAN-TAURI-JK — litellm github_copilot proxy 400 driven
// by the user's missing / expired Copilot OAuth scope. The body
// always starts with the `litellm.BadRequestError:` envelope.
// Anchor to that prefix-shaped substring so we don't catch
// unrelated 400s that merely mention litellm in passing.
"litellm.badrequesterror",
// OPENHUMAN-TAURI-J2 / -J5 / -J4 — litellm-compatible
// envelope with `"type":"not_found_error"` carrying "model 'X'
// not found". Distinct from the existing `model_not_found`
// phrase: that's the `code` field used by OpenAI-native bodies;
// this is the `type` field used by litellm/Anthropic-style
// envelopes for the same class of user-state error.
"not_found_error",
];
let lower = body.to_ascii_lowercase();
@@ -102,6 +166,63 @@ mod tests {
}
}
#[test]
fn detects_wave4_sentry_bodies() {
// Real wire bodies pulled from the OPENHUMAN-TAURI-* Sentry
// events the Wave 4 phrases drop.
for (sentry_id, body) in [
(
"R1",
r#"custom_openai API error (403 Forbidden): {"error":{"message":"This model is not available in your region.","code":403}}"#,
),
(
"R4",
r#"custom_openai API error (403 Forbidden): {"code":403,"reason":"ModelNotAllowed","message":"模型不允许访问","metadata":{"request_id":"2026051706431574423265420620337"}}"#,
),
(
"YC",
r#"custom_openai API error (401 Unauthorized): {"error":{"message":"Invalid Authentication","type":"invalid_authentication_error"}}"#,
),
(
"S5",
r#"custom_openai API error (402 Payment Required): {"error":{"message":"This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 597.","type":"insufficient_credits"}}"#,
),
(
"Y0",
r#"custom_openai API error (400 Bad Request): {"error":{"message":"{'error': '/chat/completions: Invalid model name passed in model=reasoning-v1. Call `/v1/models` to view available models for your key.'}","type":"None"}}"#,
),
(
"JN",
r#"custom_openai Responses API error: {"error":{"message":"No active credentials for provider: openai","type":"invalid_request_error","code":"model_not_found"}}"#,
),
(
"KB",
r#"OpenHuman API error (404 Not Found): {"error":{"message":"No active credentials for provider: openai","type":"invalid_request_error","code":"model_not_found"}}"#,
),
(
"JK",
r#"custom_openai API error (400 Bad Request): {"error":{"message":"litellm.BadRequestError: Github_copilotException - Bad Request. Received Model Group=github_copilot/claude-haiku-4.5\nAvailable Model Group Fallbacks=None","type":null}}"#,
),
(
"J2",
r#"custom_openai Responses API error: {"error":{"message":"model 'llama3.3' not found","type":"not_found_error","param":null,"code":null}}"#,
),
(
"J5",
r#"custom_openai API error (404 Not Found): {"error":{"message":"model 'llama3.3' not found","type":"not_found_error","param":null,"code":null}}"#,
),
(
"J4",
r#"custom_openai streaming API error (404 Not Found): {"error":{"message":"model 'llama3.3' not found","type":"not_found_error","param":null,"code":null}}"#,
),
] {
assert!(
is_provider_config_rejection_message(body),
"OPENHUMAN-TAURI-{sentry_id} body must classify as provider config-rejection: {body:?}"
);
}
}
#[test]
fn detection_is_case_insensitive() {
assert!(is_provider_config_rejection_message(