diff --git a/src/openhuman/channels/providers/web_tests.rs b/src/openhuman/channels/providers/web_tests.rs index 5cbdeedfc..c72445c3a 100644 --- a/src/openhuman/channels/providers/web_tests.rs +++ b/src/openhuman/channels/providers/web_tests.rs @@ -304,6 +304,41 @@ fn classify_inference_error_surfaces_provider_config_rejection_actionably() { } } +#[test] +fn classify_inference_error_chat_factory_empty_model_is_actionable_config() { + // TAURI-RUST-GKV — the factory's #2784 empty-model bail is a LOCAL + // string (no provider JSON body). Before the shared classifier learned + // its anchor it fell through to the generic `inference` catch-all: + // vague "something went wrong" copy AND retryable=true (a useless Retry + // button that re-fires the per-message Sentry flood). It must now route + // through the config-rejection arm to the actionable Settings → LLM + // copy, classified non-retryable so the FE hides Retry. + let raw = "[chat-factory] no model configured: role 'chat' resolved to an empty model id \ + for slug 'nvidia'. Include a model in the provider string (e.g. \ + 'nvidia:') or set default_model on the cloud_providers entry for \ + slug 'nvidia'."; + let ClassifiedError { + error_type: category, + message, + source, + retryable, + .. + } = classify_inference_error(raw); + assert_eq!( + category, "model_unavailable", + "empty-model config rejection must classify as model_unavailable, not generic: {category}" + ); + assert_eq!(source, "config", "must be sourced as user config: {source}"); + assert!( + !retryable, + "empty-model config rejection must be non-retryable (hide Retry)" + ); + assert!( + message.contains("Settings → LLM"), + "must give the actionable remediation copy: {message}" + ); +} + // ── #2364: rate-limit classification + retry-after surfacing ──── #[test] diff --git a/src/openhuman/inference/provider/config_rejection.rs b/src/openhuman/inference/provider/config_rejection.rs index bdc3b305f..4765a03ee 100644 --- a/src/openhuman/inference/provider/config_rejection.rs +++ b/src/openhuman/inference/provider/config_rejection.rs @@ -166,6 +166,21 @@ pub fn is_provider_config_rejection_message(body: &str) -> bool { // `{"error":{"message":"model field is required","code":"missing_required_field"}}` // when the request body contains an empty `"model":""` field. "model field is required", + // TAURI-RUST-GKV (~2.3k events / 1 user) — the LOCAL form of the + // 4NM empty-model state, caught one layer earlier. The #2784 guard + // in `factory::make_cloud_provider_by_slug` bails BEFORE any + // provider HTTP call when a `` provider string carries no + // model and the `cloud_providers` entry has no `default_model`: + // "[chat-factory] no model configured: role '' resolved to an + // empty model id for slug ''. Include a model in the provider + // string (e.g. ':') or set default_model …". + // User-state — the remediation is "add/pick a model in Settings → + // LLM", which the user-facing copy surfaces; Sentry has no + // remediation. Anchored on the role/slug-interpolation-free + // substring, which is also `factory::NO_MODEL_CONFIGURED_ANCHOR`; a + // round-trip test in `factory_tests.rs` couples the two so a + // wording drift fails CI instead of silently re-flooding Sentry. + "resolved to an empty model id", // TAURI-RUST-2G (~2684 events) / TAURI-RUST-2F (~950 events) — // thinking-mode model (DeepSeek-R1 / Moonshot K2-thinking on // `provider=cloud` custom_openai) rejects a follow-up turn that @@ -604,6 +619,36 @@ mod tests { )); } + #[test] + fn detects_chat_factory_empty_model_local_bail() { + // TAURI-RUST-GKV — the #2784 factory guard + // (`make_cloud_provider_by_slug`) catches the empty-model state + // BEFORE the provider HTTP call (the local form of 4NM) and bails + // with this body (role/slug interpolated). Verbatim from Sentry + // issue 18482 (role='chat', slug='nvidia'). + let body = "[chat-factory] no model configured: role 'chat' resolved to an empty model id \ + for slug 'nvidia'. Include a model in the provider string (e.g. \ + 'nvidia:') or set default_model on the cloud_providers entry for \ + slug 'nvidia'."; + assert!( + is_provider_config_rejection_message(body), + "TAURI-RUST-GKV empty-model bail must classify as provider config-rejection: {body:?}" + ); + // Bare anchor on its own (the literal shared with + // `factory::NO_MODEL_CONFIGURED_ANCHOR`). + assert!(is_provider_config_rejection_message( + "resolved to an empty model id" + )); + // Negative: a near-miss model-resolution error that does NOT carry + // the anchor (or any other phrase) must stay Sentry-actionable. + assert!( + !is_provider_config_rejection_message( + "could not resolve the model registry for slug 'nvidia'" + ), + "unrelated model-resolution error must not classify on the GKV anchor" + ); + } + #[test] fn unknown_model_helper_rejects_other_config_rejection_phrases() { // Polarity exception must stay narrow: other config-rejection diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 0e595eaf7..5d31460b8 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -64,6 +64,16 @@ pub const CLAUDE_AGENT_SDK_PROVIDER: &str = "claude_agent_sdk"; /// instead of silently routing through the managed OpenHuman backend. pub const BYOK_INCOMPLETE_SENTINEL: &str = "__byok_incomplete__"; +/// Interpolation-free substring of the empty-model bail emitted by +/// [`make_cloud_provider_by_slug`] when a `` provider string carries +/// no model and the `cloud_providers` entry has no `default_model` (the +/// #2784 guard). The Sentry-demotion + user-copy classifier +/// [`super::is_provider_config_rejection_message`] keys on this exact literal, +/// and a round-trip test in `factory_tests.rs` asserts the bail body still +/// contains it — so a wording drift fails CI instead of silently re-flooding +/// Sentry (TAURI-RUST-GKV). +pub(crate) const NO_MODEL_CONFIGURED_ANCHOR: &str = "resolved to an empty model id"; + fn is_abstract_tier_model(model: &str) -> bool { use crate::openhuman::config::{ MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index eb602622f..e85b90d81 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -453,6 +453,23 @@ fn cloud_provider_with_no_model_and_no_default_rejected() { msg.contains("nvidia-nim"), "error must name the slug; got: {msg}" ); + + // TAURI-RUST-GKV coupling — the SAME bail body that floods Sentry must: + // (a) still contain the classifier anchor const, and + // (b) be recognised by the shared config-rejection classifier + // (which both demotes the Sentry event AND drives the actionable + // user-facing copy in `classify_inference_error`). + // If the bail wording drifts off the anchor, (a) fails; if the + // classifier phrase drifts, (b) fails — CI catches either direction, so + // the demotion can never silently regress into an error flood. + assert!( + msg.contains(super::NO_MODEL_CONFIGURED_ANCHOR), + "bail body must contain NO_MODEL_CONFIGURED_ANCHOR; got: {msg}" + ); + assert!( + crate::openhuman::inference::provider::is_provider_config_rejection_message(&msg), + "empty-model bail must classify as provider config-rejection: {msg}" + ); } #[test]