Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/openhuman/inference/provider/compatible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
250 changes: 232 additions & 18 deletions src/openhuman/inference/provider/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> = 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}"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// 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<String> = 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}"
);
}
}
Loading