diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs index f177dd2622..66cade1834 100644 --- a/src/openhuman/inference/provider/compatible.rs +++ b/src/openhuman/inference/provider/compatible.rs @@ -1479,7 +1479,20 @@ impl Provider for OpenAiCompatibleProvider { format!("{} API error ({status}): {sanitized}", self.name), status, ); - if super::is_budget_exhausted_http_400(status, &error) { + if super::is_backend_auth_failure(self.name.as_str(), status) { + // Backend rejected the app session JWT (401/403): expected + // session-expiry (token expired/revoked/rotated), not a code + // bug. Publish SessionExpired so the credentials subscriber + // drives reauth and the scheduler-gate halts downstream LLM + // work, and skip the Sentry report (TAURI-RUST-N). Mirrors the + // `is_backend_auth_failure` arm in `super::api_error`. + super::publish_backend_session_expired( + "chat_completions", + self.name.as_str(), + status, + &message, + ); + } else if super::is_budget_exhausted_http_400(status, &error) { super::log_budget_exhausted_http_400( "chat_completions", self.name.as_str(), diff --git a/src/openhuman/inference/provider/ops.rs b/src/openhuman/inference/provider/ops.rs index 05f343c756..2f3929a82b 100644 --- a/src/openhuman/inference/provider/ops.rs +++ b/src/openhuman/inference/provider/ops.rs @@ -701,6 +701,50 @@ pub(super) fn log_context_window_exceeded( ); } +/// Whether a provider non-2xx response is the OpenHuman **backend** rejecting +/// the app session JWT (`401`/`403`). This is expected user-session state +/// (token expired / revoked / rotated server-side), not a product bug — the +/// auth domain owns recovery. `401`/`403` from **other** providers (OpenAI, +/// Anthropic, …) mean a misconfigured BYO API key and stay Sentry-actionable, +/// so the predicate is provider-scoped to [`openhuman_backend::PROVIDER_LABEL`]. +pub(super) fn is_backend_auth_failure(provider: &str, status: reqwest::StatusCode) -> bool { + matches!(status.as_u16(), 401 | 403) && provider == openhuman_backend::PROVIDER_LABEL +} + +/// Handle a backend session-expiry auth failure: publish a +/// [`crate::core::event_bus::DomainEvent::SessionExpired`] so the credentials +/// subscriber clears the session and flips the scheduler-gate signed-out +/// override (halting downstream LLM work — see OPENHUMAN-TAURI-1T), and skip +/// the Sentry report. Mirrors the `is_auth_failure && is_backend` arm in +/// [`api_error`], factored out for the hand-rolled provider HTTP-error chains +/// in [`super::compatible::OpenAiCompatibleProvider`] which consume the +/// response body inline and so can't delegate to `api_error`. The +/// `chat_completions` chain lacked this branch and reported the backend +/// `401 Invalid token` to Sentry — that drift was TAURI-RUST-N. +/// +/// `message` is the already-formatted `"{provider} API error ({status}): …"` +/// string; it embeds the sanitized body, but the prefix and caller-controlled +/// provider name aren't scrubbed, so re-run [`sanitize_api_error`] on the final +/// string before it reaches the SessionExpired subscriber's logs. +pub(super) fn publish_backend_session_expired( + operation: &str, + provider: &str, + status: reqwest::StatusCode, + message: &str, +) { + tracing::warn!( + domain = "llm_provider", + operation = operation, + provider = provider, + status = status.as_u16(), + "[llm_provider] backend auth failure ({status}) — publishing SessionExpired" + ); + crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::SessionExpired { + source: "llm_provider.openhuman_backend".to_string(), + reason: sanitize_api_error(message), + }); +} + /// Build a sanitized provider error from a failed HTTP response. /// /// Reports the failure to Sentry with `provider` and `status` tags so @@ -748,24 +792,10 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E let is_context_window_exceeded = is_context_window_exceeded_message(&body); if is_auth_failure && is_backend { - tracing::warn!( - domain = "llm_provider", - operation = "api_error", - provider = provider, - status = status_str.as_str(), - "[llm_provider] backend auth failure ({status}) — publishing SessionExpired" - ); - // `message` already embeds the sanitized body via - // `sanitize_api_error(&body)`, but the leading `{provider} API - // error ({status})` prefix and any caller-controlled provider - // name aren't scrubbed — re-run sanitize on the final string so - // the SessionExpired subscriber's logs never persist secrets. - crate::core::event_bus::publish_global( - crate::core::event_bus::DomainEvent::SessionExpired { - source: "llm_provider.openhuman_backend".to_string(), - reason: sanitize_api_error(&message), - }, - ); + // Single source of truth for backend session-expiry handling (warn + + // SessionExpired publish + final-string sanitize) — shared with the + // hand-rolled `chat_completions` chain in `compatible.rs`. + publish_backend_session_expired("api_error", provider, status, &message); } else if is_budget_exhausted_user_state { log_budget_exhausted_http_400("api_error", provider, None, status); } else if is_custom_openai_upstream_bad_request { @@ -1819,4 +1849,188 @@ mod tests { ); } } + + /// `is_backend_auth_failure` is the polarity guard that decides whether a + /// 401/403 is the OpenHuman backend's expired session (silence + drive + /// reauth) or a third-party BYO-key rejection (actionable, must reach + /// Sentry). Getting this wrong in either direction is a regression: + /// over-matching silences real misconfig; under-matching is TAURI-RUST-N. + #[test] + fn is_backend_auth_failure_only_matches_openhuman_backend_401_403() { + use reqwest::StatusCode; + let backend = crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL; + + assert!(is_backend_auth_failure(backend, StatusCode::UNAUTHORIZED)); + assert!(is_backend_auth_failure(backend, StatusCode::FORBIDDEN)); + + // Non-auth backend statuses stay reportable (real server bugs / transient). + for s in [ + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::BAD_REQUEST, + StatusCode::NOT_FOUND, + ] { + assert!( + !is_backend_auth_failure(backend, s), + "backend {s} must not be treated as session-expiry" + ); + } + + // Third-party BYO-key 401/403 (user's own key revoked) must NOT be + // silenced — that is actionable misconfiguration for Sentry. + for provider in ["custom_openai", "OpenAI", "Anthropic", "openrouter"] { + assert!( + !is_backend_auth_failure(provider, StatusCode::UNAUTHORIZED), + "{provider} 401 must reach Sentry as actionable BYO-key error" + ); + assert!( + !is_backend_auth_failure(provider, StatusCode::FORBIDDEN), + "{provider} 403 must reach Sentry as actionable BYO-key error" + ); + } + } + + /// `publish_backend_session_expired` must emit a `SessionExpired` event on + /// the `auth` domain with the canonical source and a sanitized reason, so + /// the credentials subscriber can drive reauth. + #[tokio::test] + async fn publish_backend_session_expired_emits_sanitized_session_expired() { + use crate::core::event_bus::{global, init_global, DomainEvent}; + + init_global(1024); + let mut rx = global().expect("event bus initialized").raw_receiver(); + + // `TEST_MARKER_A` makes this event distinguishable from the sibling + // `chat_completions_backend_401_*` test's event on the shared global + // bus (both run in parallel against the same singleton). The `sk-` + // token probes that `sanitize_api_error` actually scrubs secrets out + // of the SessionExpired reason rather than just emitting the event. + let secret = "sk-LIVEA0123456789abcdefSECRET"; + let msg = format!( + r#"OpenHuman API error (401 Unauthorized): {{"success":false,"error":"TEST_MARKER_A Invalid token {secret}"}}"# + ); + publish_backend_session_expired( + "chat_completions", + crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL, + reqwest::StatusCode::UNAUTHORIZED, + &msg, + ); + + let mut reason_seen: Option = None; + loop { + match rx.try_recv() { + Ok(DomainEvent::SessionExpired { source, reason }) => { + if source == "llm_provider.openhuman_backend" + && reason.contains("TEST_MARKER_A") + { + reason_seen = Some(reason); + break; + } + } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + let reason = reason_seen.expect( + "publish_backend_session_expired must emit SessionExpired(source=llm_provider.openhuman_backend) carrying TEST_MARKER_A", + ); + assert!( + reason.contains("[REDACTED]"), + "sanitize_api_error must redact the sk- token in the reason: {reason}" + ); + assert!( + !reason.contains(secret), + "raw secret must not survive into the SessionExpired reason: {reason}" + ); + } + + /// End-to-end regression for TAURI-RUST-N: a backend `401 Invalid token` + /// on the hand-rolled `chat_completions` path must publish `SessionExpired` + /// (driving reauth) and surface the typed error — NOT spam Sentry. The + /// provider is labelled exactly like the OpenHuman backend provider, which + /// is what gates the backend-auth-failure branch. + #[tokio::test] + async fn chat_completions_backend_401_publishes_session_expired() { + use crate::core::event_bus::{global, init_global, DomainEvent}; + use axum::routing::post; + + init_global(1024); + let mut rx = global().expect("event bus initialized").raw_receiver(); + + async fn unauthorized_handler() -> Response { + // `TEST_MARKER_B` distinguishes this event from the sibling + // `publish_backend_session_expired_*` test on the shared global + // bus; the `sk-` token probes end-to-end redaction through + // `api_error` → `publish_backend_session_expired`. + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "success": false, + "error": "TEST_MARKER_B Invalid token sk-LIVEB9876543210fedcbaSECRET" + })), + ) + .into_response() + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let app = Router::new().route("/chat/completions", post(unauthorized_handler)); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + + let provider = + crate::openhuman::inference::provider::compatible::OpenAiCompatibleProvider::new_no_responses_fallback( + crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL, + &format!("http://{addr}"), + Some("expired-jwt"), + crate::openhuman::inference::provider::compatible::AuthStyle::Bearer, + ); + + let err = crate::openhuman::inference::provider::traits::Provider::chat_with_system( + &provider, + None, + "hi", + "reasoning-quick-v1", + 0.0, + ) + .await + .expect_err("backend 401 must surface as an error"); + let msg = err.to_string(); + assert!( + msg.contains("OpenHuman API error (401") && msg.contains("Invalid token"), + "error must carry the backend 401 envelope: {msg}" + ); + + let mut reason_seen: Option = None; + loop { + match rx.try_recv() { + Ok(DomainEvent::SessionExpired { source, reason }) => { + if source == "llm_provider.openhuman_backend" + && reason.contains("TEST_MARKER_B") + { + reason_seen = Some(reason); + break; + } + } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + let reason = reason_seen.expect( + "backend 401 on chat_completions must publish SessionExpired carrying TEST_MARKER_B, not report to Sentry", + ); + assert!( + reason.contains("[REDACTED]"), + "sanitize_api_error must redact the sk- token end-to-end: {reason}" + ); + assert!( + !reason.contains("sk-LIVEB9876543210fedcbaSECRET"), + "raw secret must not survive into the SessionExpired reason: {reason}" + ); + } }