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
93 changes: 89 additions & 4 deletions src/openhuman/inference/provider/config_rejection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,32 @@
//!
//! ## Provider-aware polarity (important)
//!
//! The phrases below are emitted by **third-party upstream APIs**
//! Most of the phrases below are emitted by **third-party upstream APIs**
//! (DeepSeek / OpenRouter / Moonshot). The OpenHuman hosted backend
//! resolves tier aliases natively and never emits "supported API model
//! names are deepseek-…" or "invalid temperature: only 1 is allowed" — so
//! the phrase set is intrinsically scoped to custom providers. The
//! that phrase set is intrinsically scoped to custom providers. The
//! HTTP-layer wrapper [`super::ops::is_provider_config_rejection_http`]
//! additionally guards on `provider != openhuman_backend::PROVIDER_LABEL`
//! so a model-rejection from our **own** backend (which would be a real
//! polarity-guards those phrases on `provider !=
//! openhuman_backend::PROVIDER_LABEL` so a model-rejection from our
//! **own** backend that we did not expect (which would be a real
//! regression we sent it a bad request) still reaches Sentry. The
//! message-only predicate is consumed by
//! [`crate::core::observability::expected_error_kind`] for the
//! re-reported error that escapes the provider layer and is raised again
//! by `agent.run_single` / `web_channel.run_chat_task`.
//!
//! **Exception: the OpenAI-compatible "unknown model" shape** (`Model 'X'
//! is not available. Use GET /openai/v1/models …`) is now emitted by the
//! OpenHuman hosted backend too, in response to user-configured model ids
//! that aren't in the backend's registry. Pinned by
//! [`is_openai_compatible_unknown_model_message`]. The HTTP-layer wrapper
//! drops the polarity guard for that specific shape so the same body is
//! treated as user-state regardless of provider — see TAURI-RUST-2Z1
//! where a user-typed `MiniMax-M2.7-highspeed` model id (plus two
//! `custom:` fallback variants from their own `model_fallbacks` config)
//! was rejected with this wire shape and otherwise reached Sentry.
//!
//! Keep the list deliberately tight: a false positive demotes a real
//! provider/backend bug to an info log.

Expand Down Expand Up @@ -142,6 +154,29 @@ pub fn is_provider_config_rejection_message(body: &str) -> bool {
PHRASES.iter().any(|phrase| lower.contains(phrase))
}

/// Returns true if a provider error body matches the OpenAI-compatible
/// "unknown model" shape — anchored on the `/openai/v1/models`
/// remediation hint the upstream returns alongside `Model 'X' is not
/// available.`.
///
/// This is a strict subset of [`is_provider_config_rejection_message`]:
/// the same phrase already lives in that predicate's list. The narrower
/// helper exists so the HTTP-layer wrapper
/// ([`super::ops::is_provider_config_rejection_http`]) can drop its
/// `provider != openhuman_backend::PROVIDER_LABEL` polarity guard for
/// this specific body shape — the OpenHuman hosted backend now emits the
/// same OpenAI-compatible "Model 'X' is not available" wire body in
/// response to user-configured unknown model ids, so the original
/// polarity assumption ("only third-party providers speak this dialect")
/// no longer holds.
///
/// Drops TAURI-RUST-2Z1 (per-attempt) — the aggregate sibling
/// TAURI-RUST-2Z2 is already covered by the message-only classifier in
/// [`crate::core::observability::expected_error_kind`].
pub fn is_openai_compatible_unknown_model_message(body: &str) -> bool {
body.to_ascii_lowercase().contains("/openai/v1/models")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -252,4 +287,54 @@ mod tests {
);
}
}

#[test]
fn unknown_model_helper_matches_openai_compatible_bodies() {
// TAURI-RUST-2Z1 — the OpenHuman hosted backend now emits the
// OpenAI-compatible "Model 'X' is not available" wire body for
// user-configured unknown model ids. The helper is anchored on
// the `/openai/v1/models` remediation hint so the same body shape
// matches whether it came from a third-party `custom_openai`
// upstream or our own backend.
for body in [
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Model 'MiniMax-M2.7-highspeed' is not available. Use GET /openai/v1/models to list available models."}"#,
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Model 'custom:MiniMax-M2.7' is not available. Use GET /openai/v1/models to list available models."}"#,
"Model 'deepseek-v4-pro' is not available. Use GET /openai/v1/models to list available models.",
] {
assert!(
is_openai_compatible_unknown_model_message(body),
"TAURI-RUST-2Z1 body must classify as openai-compatible unknown model: {body:?}"
);
// Sanity: must remain a member of the broader phrase set so
// the message-only classifier in
// `crate::core::observability::expected_error_kind` keeps
// demoting the aggregate (TAURI-RUST-2Z2).
assert!(
is_provider_config_rejection_message(body),
"broader classifier must continue to match: {body:?}"
);
}
}

#[test]
fn unknown_model_helper_rejects_other_config_rejection_phrases() {
// Polarity exception must stay narrow: other config-rejection
// shapes (DeepSeek `supported api model names are`, Moonshot
// `invalid temperature`, OpenRouter `requires more credits`, …)
// must still go through the provider-polarity guard so a
// hypothetical regression where our own backend emits one of
// those phrases reaches Sentry.
for body in [
"The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed reasoning-v1.",
"invalid temperature: only 1 is allowed for this model",
"The model `gpt-5.5` does not exist or you do not have access to it.",
r#"{"error":{"message":"model not found","code":"model_not_found"}}"#,
"This request requires more credits, or fewer max_tokens.",
] {
assert!(
!is_openai_compatible_unknown_model_message(body),
"{body:?} must NOT match the narrow openai-compatible-unknown-model helper"
);
}
}
}
4 changes: 3 additions & 1 deletion src/openhuman/inference/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub use traits::{
};

pub use billing_error::is_budget_exhausted_message;
pub use config_rejection::is_provider_config_rejection_message;
pub use config_rejection::{
is_openai_compatible_unknown_model_message, is_provider_config_rejection_message,
};
pub use factory::{create_chat_provider, provider_for_role, BYOK_INCOMPLETE_SENTINEL};
pub use ops::*;
85 changes: 72 additions & 13 deletions src/openhuman/inference/provider/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,21 +478,44 @@ pub(super) fn log_provider_access_policy_denied_http_403(
/// abstract tier leaked to a custom provider, model-specific temperature
/// constraint) that should be demoted from Sentry to an info log.
///
/// Provider-aware (inverted polarity vs. the 401/403 backend rule): the
/// same body from the OpenHuman **backend** stays Sentry-actionable —
/// that would mean we sent our own backend a bad request (a regression,
/// e.g. #2079). Only client errors from a *custom / third-party*
/// provider are user-config state. Restricted to the observed shapes
/// (400 invalid-param / unknown-model, 404 model-does-not-exist, 422
/// unprocessable); 408/429 are transient and handled separately.
/// Provider-aware (inverted polarity vs. the 401/403 backend rule): for
/// most config-rejection phrases the same body from the OpenHuman
/// **backend** stays Sentry-actionable — that would mean we sent our own
/// backend a bad request (a regression, e.g. #2079). Restricted to the
/// observed shapes (400 invalid-param / unknown-model, 404
/// model-does-not-exist, 422 unprocessable); 408/429 are transient and
/// handled separately.
///
/// **Exception: OpenAI-compatible "unknown model"** (`Model 'X' is not
/// available. Use GET /openai/v1/models …`). The OpenHuman backend now
/// emits this exact body for user-configured unknown model ids, so it is
/// user-state regardless of provider — the polarity guard is dropped for
/// this specific shape (TAURI-RUST-2Z1). See
/// [`super::is_openai_compatible_unknown_model_message`].
pub(super) fn is_provider_config_rejection_http(
status: reqwest::StatusCode,
provider: &str,
body: &str,
) -> bool {
matches!(status.as_u16(), 400 | 404 | 422)
&& provider != openhuman_backend::PROVIDER_LABEL
&& super::is_provider_config_rejection_message(body)
if !matches!(status.as_u16(), 400 | 404 | 422) {
return false;
}
if !super::is_provider_config_rejection_message(body) {
return false;
}
// OpenAI-compatible "unknown model" body is user-state regardless of
// provider — both third-party `custom_openai` upstreams and our own
// OpenHuman backend now emit it for user-configured model ids that
// aren't in the registry (TAURI-RUST-2Z1).
if super::is_openai_compatible_unknown_model_message(body) {
return true;
}
// Remaining config-rejection phrases (DeepSeek `supported api model
// names are`, Moonshot `invalid temperature`, litellm envelopes, …)
// are intrinsically scoped to third-party providers — keep the
// polarity guard so a regression where our own backend emits one of
// those still reaches Sentry.
provider != openhuman_backend::PROVIDER_LABEL
}

pub(super) fn log_provider_config_rejection(
Expand Down Expand Up @@ -1375,14 +1398,50 @@ mod tests {

#[test]
fn openhuman_backend_same_body_is_not_suppressed() {
// Inverted polarity: a model-rejection from our OWN backend
// means we sent it a bad request — a real regression that must
// still reach Sentry. (Mirror of the 401/403 backend rule.)
// Inverted polarity: for tier-leak / temperature / litellm /
// OpenRouter-style phrases, the OpenHuman backend never
// emits them, so the same body from our OWN backend would
// mean we sent it a bad request — a real regression that
// must still reach Sentry. (Mirror of the 401/403 backend
// rule.)
assert!(!is_provider_config_rejection_http(
reqwest::StatusCode::BAD_REQUEST,
openhuman_backend::PROVIDER_LABEL,
TIER_LEAK_BODY,
));
assert!(!is_provider_config_rejection_http(
reqwest::StatusCode::BAD_REQUEST,
openhuman_backend::PROVIDER_LABEL,
TEMP_BODY,
));
}

#[test]
fn openhuman_backend_openai_compatible_unknown_model_is_suppressed() {
// TAURI-RUST-2Z1 — the OpenHuman backend DOES emit the
// OpenAI-compatible "Model 'X' is not available. Use GET
// /openai/v1/models …" wire body for user-configured unknown
// model ids (here `MiniMax-M2.7-highspeed` and two
// `custom:`-prefixed fallback variants from the user's own
// `model_fallbacks` config). That's user-state, not a
// regression — drop the polarity guard for this specific
// shape so the per-attempt event stops reaching Sentry.
// (The aggregate sibling TAURI-RUST-2Z2 is already covered by
// `expected_error_kind` via the broader message-only
// classifier.)
for body in [
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Model 'MiniMax-M2.7-highspeed' is not available. Use GET /openai/v1/models to list available models."}"#,
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Model 'custom:MiniMax-M2.7' is not available. Use GET /openai/v1/models to list available models."}"#,
] {
assert!(
is_provider_config_rejection_http(
reqwest::StatusCode::BAD_REQUEST,
openhuman_backend::PROVIDER_LABEL,
body,
),
"TAURI-RUST-2Z1 body must be suppressed for openhuman backend: {body:?}"
);
}
}

#[test]
Expand Down
Loading