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
113 changes: 113 additions & 0 deletions src/core/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ pub enum ExpectedErrorKind {
/// 4xx body embedded, which would otherwise escape the
/// [`is_backend_user_error_message`] 4xx-only matcher.
ProviderUserState,
/// A user-configured custom cloud provider (`custom_openai` → DeepSeek
/// / OpenRouter / Moonshot / …) rejected the request because of the
/// user's **model / parameter configuration**: an OpenHuman abstract
/// tier alias leaked to a provider that only speaks its native ids
/// (#2079), an unknown / stale model pin (#2202), or a model-specific
/// temperature constraint (#2076 — Moonshot Kimi K2). The provider
/// HTTP layer (`providers::ops::api_error`) already demotes its own
/// per-attempt event; this catches the *re-report* when the same
/// error is raised again by `agent.run_single` /
/// `web_channel.run_chat_task` under `domain=agent` / `web_channel`.
/// Deterministic user-config state surfaced in the UI — Sentry has no
/// remediation path (OPENHUMAN-TAURI-WJ / -QW / -HB / -NH, ~273
/// events). See
/// [`crate::openhuman::inference::provider::is_provider_config_rejection_message`]
/// for the polarity contract and exact body shapes.
ProviderConfigRejection,
LocalAiCapabilityUnavailable,
BudgetExhausted,
SessionExpired,
Expand Down Expand Up @@ -120,6 +136,15 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
if is_backend_user_error_message(&lower) {
return Some(ExpectedErrorKind::BackendUserError);
}
// Provider config-rejection (unknown model / abstract tier leaked to a
// custom provider / model-specific temperature). Body-shape based and
// intrinsically scoped to third-party providers — the OpenHuman
// backend never emits these phrases. See the predicate's polarity
// contract. Drops OPENHUMAN-TAURI-WJ / -QW / -HB / -NH re-reports
// (#2079 / #2076 / #2202).
if crate::openhuman::inference::provider::is_provider_config_rejection_message(message) {
return Some(ExpectedErrorKind::ProviderConfigRejection);
}
if is_local_ai_capability_unavailable_message(&lower) {
return Some(ExpectedErrorKind::LocalAiCapabilityUnavailable);
}
Expand Down Expand Up @@ -503,6 +528,26 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
"[observability] {domain}.{operation} skipped expected provider-user-state error: {message}"
);
}
ExpectedErrorKind::ProviderConfigRejection => {
// User-config state: a custom cloud provider rejected the
// request because of the user's model / parameter setup — an
// OpenHuman abstract tier alias leaked to a provider that only
// speaks its native ids (#2079), an unknown / stale model pin
// (#2202), or a model-specific temperature constraint (#2076,
// Moonshot Kimi K2). The provider HTTP layer already demoted
// its own per-attempt event; this is the re-report raised
// again by agent.run_single / web_channel.run_chat_task. The
// UI surfaces an actionable "fix your model/provider settings"
// error — Sentry has no remediation path
// (OPENHUMAN-TAURI-WJ / -QW / -HB / -NH).
tracing::info!(
domain = domain,
operation = operation,
kind = "provider_config_rejection",
error = %message,
"[observability] {domain}.{operation} skipped expected provider config-rejection error: {message}"
);
}
ExpectedErrorKind::LocalAiCapabilityUnavailable => {
// User-state condition: the local-AI service refused a
// capability (vision summarization, vision asset download)
Expand Down Expand Up @@ -1497,6 +1542,57 @@ mod tests {
);
}

#[test]
fn classifies_provider_config_rejection() {
// #2079 — an OpenHuman abstract tier alias leaked to a custom
// provider; raised again by `agent.run_single` /
// `web_channel.run_chat_task` so it escapes the provider-layer
// demotion and reaches `report_error_or_expected` here.
assert_eq!(
expected_error_kind(
"agent.run_single failed: custom_openai API error (400 Bad Request): \
The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \
but you passed reasoning-v1."
),
Some(ExpectedErrorKind::ProviderConfigRejection)
);
// #2076 — Moonshot Kimi K2 temperature constraint.
assert_eq!(
expected_error_kind(
"custom_openai API error (400): invalid temperature: only 1 is allowed for this model"
),
Some(ExpectedErrorKind::ProviderConfigRejection)
);
// #2202 — unknown / stale model pin (OpenAI-compatible body).
assert_eq!(
expected_error_kind(
"custom_openai API error (400): Model 'claude-opus-4-7' is not available. \
Use GET /openai/v1/models to list available models."
),
Some(ExpectedErrorKind::ProviderConfigRejection)
);
}

#[test]
fn does_not_classify_unrelated_provider_failures_as_config_rejection() {
// Inverted polarity / scope guard: a 5xx or a generic 4xx with no
// config-rejection body must still reach Sentry as actionable.
// (The OpenHuman backend never emits these phrases, so the
// message-level predicate is intrinsically custom-provider scoped;
// the HTTP-layer twin enforces the non-backend guard explicitly.)
assert_eq!(
expected_error_kind("custom_openai API error (500): internal server error"),
None
);
assert_eq!(
expected_error_kind(
"custom_openai API error (400 Bad Request): missing required field 'messages'"
),
None,
"generic 4xx without a config-rejection body must NOT demote"
);
}

#[test]
fn unrelated_missing_required_fields_classifies_as_accepted_false_positive() {
// Documents the breadth of the `"missing required fields"` arm —
Expand Down Expand Up @@ -2138,6 +2234,23 @@ mod tests {
"provider_chat",
&[("provider", "ollama")],
);
// #2079 / #2076 / #2202 — exercises the expected_error_kind
// ProviderConfigRejection branch AND the report_expected_message
// skip-log arm (the agent/web-channel re-report demotion path).
report_error_or_expected(
"agent.run_single failed: custom_openai API error (400 Bad Request): \
The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \
but you passed reasoning-v1.",
"agent",
"native_chat",
&[("provider", "custom_openai")],
);
report_error_or_expected(
"custom_openai API error (400): invalid temperature: only 1 is allowed for this model",
"web_channel",
"run_chat_task",
&[("provider", "custom_openai")],
);
}

fn event_with_message(msg: &str) -> sentry::protocol::Event<'static> {
Expand Down
17 changes: 17 additions & 0 deletions src/openhuman/channels/providers/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,23 @@ fn classify_inference_error(err: &str) -> (&'static str, String) {
err,
),
)
} else if crate::openhuman::inference::provider::is_provider_config_rejection_message(err) {
// #2079 / #2076 / #2202: an OpenHuman abstract tier alias leaked to
// a custom provider, a stale model pin, or a model-specific
// temperature constraint. Checked BEFORE the generic
// model-unavailable arm so config-rejection bodies that also
// contain "model"/"does not exist"/"does not have access" get the
// specific "Settings → LLM" remediation instead of the generic
// copy. Shared predicate keeps this in lockstep with the
// Sentry-demotion classifier.
(
"model_unavailable",
with_provider_detail(
"Your AI provider rejected the request's model or temperature setting. \
Check your model and routing in Settings → LLM.",
err,
),
)
} else if lower.contains("model")
&& (lower.contains("not found")
|| lower.contains("unavailable")
Expand Down
38 changes: 37 additions & 1 deletion src/openhuman/channels/providers/web_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,52 @@ fn extract_provider_error_detail_returns_none_for_transport_errors() {

#[test]
fn classify_inference_error_quotes_model_unavailable_detail() {
// A stale model pin (`model_not_found` / "does not exist or you do not
// have access") is the #2202 config-rejection class: it now resolves
// via the provider-config-rejection arm (ordered before the generic
// model-unavailable arm) and gets the actionable Settings remediation,
// while still classifying as `model_unavailable` and quoting the
// upstream detail.
let raw = r#"custom_openai API error (404 Not Found): {"error":{"message":"The model `gpt-5.5` does not exist or you do not have access to it.","code":"model_not_found"}}"#;
let (category, message) = classify_inference_error(raw);
assert_eq!(category, "model_unavailable");
assert!(message.contains("Check your model settings"));
assert!(
message.contains("Settings → LLM"),
"config-rejection must give the actionable remediation: {message}"
);
assert!(
message.contains("gpt-5.5"),
"should quote model name: {message}"
);
}

#[test]
fn classify_inference_error_surfaces_provider_config_rejection_actionably() {
// #2079 / #2076 / #2202: before this arm these fell through to the
// generic "inference" bucket and the user saw no actionable
// remediation. Each must now classify as `model_unavailable` with the
// "fix your model/routing" copy, and quote the upstream detail.
let cases = [
// #2079 — abstract tier alias leaked to a custom provider.
r#"custom_openai API error (400 Bad Request): {"error":{"message":"The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed reasoning-v1.","type":"invalid_request_error"}}"#,
// #2076 — Moonshot Kimi K2 only accepts temperature: 1.
r#"custom_openai API error (400): {"error":{"message":"invalid temperature: only 1 is allowed for this model","type":"invalid_request_error"}}"#,
// #2202 — unknown / stale model pin.
r#"custom_openai API error (400): {"error":{"message":"Model 'claude-opus-4-7' is not available. Use GET /openai/v1/models to list available models."}}"#,
];
for raw in cases {
let (category, message) = classify_inference_error(raw);
assert_eq!(
category, "model_unavailable",
"config-rejection must classify as model_unavailable, not generic: {raw}"
);
assert!(
message.contains("Settings → LLM"),
"must give actionable remediation: {message}"
);
}
}

#[test]
fn generic_error_copy_is_sanitized_and_has_discord_report_action() {
let message = generic_inference_error_user_message();
Expand Down
134 changes: 134 additions & 0 deletions src/openhuman/inference/provider/config_rejection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! Classifier for **provider configuration-rejection** errors.
//!
//! When OpenHuman talks to a user-configured custom cloud endpoint
//! (`custom_openai` → DeepSeek / OpenRouter / Moonshot / …) the upstream
//! API rejects requests whose model id or sampling params it doesn't
//! understand:
//!
//! - `"The supported API model names are deepseek-v4-pro or
//! deepseek-v4-flash, but you passed reasoning-v1."` (#2079 — an
//! OpenHuman abstract tier alias leaked to a provider that only speaks
//! its own native ids)
//! - `"Model 'deepseek-v4-pro' is not available. Use GET
//! /openai/v1/models to list available models."` (#2202)
//! - `"invalid temperature: only 1 is allowed for this model"` (#2076 —
//! 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)
//!
//! 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-aware polarity (important)
//!
//! 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
//! 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
//! 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`.
//!
//! Keep the list deliberately tight: a false positive demotes a real
//! provider/backend bug to an info log.

/// Returns true if a provider error body indicates the request was
/// rejected because of the user's model / parameter **configuration**
/// (unknown model id, abstract tier leaked to a custom provider,
/// model-specific temperature constraint), as opposed to a transient
/// failure or a server bug.
///
/// Case-insensitive substring match. See the module docs for the polarity
/// contract and the OPENHUMAN-TAURI Sentry issues each phrase drops.
pub fn is_provider_config_rejection_message(body: &str) -> bool {
const PHRASES: &[&str] = &[
// #2079 — an OpenHuman abstract tier alias (`reasoning-v1`,
// `chat-v1`, …) reached a custom provider that lists its own
// native ids back at us.
"supported api model names are",
// #2202 — OpenAI-compatible "unknown model" body. The
// `/openai/v1/models` remediation hint is the stable, unique
// anchor (the quoted model id varies per user).
"/openai/v1/models",
// OpenAI / OpenRouter stale-pin shape (`claude-opus-4-7`,
// `gpt-5.5`, …) — model removed or no access.
"does not exist or you do not have access",
"model_not_found",
// #2076 — Moonshot Kimi K2 only accepts `temperature: 1`.
"invalid temperature",
"only 1 is allowed for this model",
// Our own actionable error once a proper tier→model resolution
// is in place (keeps this classifier stable across that fix).
"is an abstract tier",
];

let lower = body.to_ascii_lowercase();
PHRASES.iter().any(|phrase| lower.contains(phrase))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn detects_real_sentry_bodies() {
// The exact upstream bodies from OPENHUMAN-TAURI-WJ / -QW / -HB
// / -NH and the stale-pin family.
for body in [
"The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed reasoning-v1.",
"Model 'deepseek-v4-pro' is not available. Use GET /openai/v1/models to list available models.",
"Model 'claude-opus-4-7' is not available. Use GET /openai/v1/models to list available models.",
"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"}}"#,
"Model 'reasoning-v1' is an abstract tier — configure a concrete model for your custom provider",
] {
assert!(
is_provider_config_rejection_message(body),
"{body:?} must classify as a provider config-rejection user-state"
);
}
}

#[test]
fn detection_is_case_insensitive() {
assert!(is_provider_config_rejection_message(
"INVALID TEMPERATURE: ONLY 1 IS ALLOWED FOR THIS MODEL"
));
assert!(is_provider_config_rejection_message(
"The Supported API Model Names Are gpt-4o or gpt-4o-mini"
));
}

#[test]
fn ignores_transient_and_server_and_unrelated() {
// Must NOT demote: transient/server failures and generic 4xx
// that carry no config-rejection signal — those stay Sentry
// actionable. (A real backend bug must not be silenced.)
for body in [
"Internal server error",
"503 Service Unavailable",
"Bad request: missing field",
"rate limit exceeded, retry after 1s",
"insufficient budget — add credits",
"",
] {
assert!(
!is_provider_config_rejection_message(body),
"{body:?} must NOT classify as a provider config-rejection"
);
}
}
}
2 changes: 2 additions & 0 deletions src/openhuman/inference/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod compatible_dump;
pub mod compatible_parse;
pub mod compatible_stream;
pub mod compatible_types;
pub mod config_rejection;
pub mod factory;
pub mod openhuman_backend;
pub mod ops;
Expand All @@ -27,5 +28,6 @@ pub use traits::{
};

pub use billing_error::is_budget_exhausted_message;
pub use config_rejection::is_provider_config_rejection_message;
pub use factory::{create_chat_provider, provider_for_role};
pub use ops::*;
Loading
Loading