fix(inference): demote chat-factory empty-model bail to expected config-rejection (#4001) (#4011)

This commit is contained in:
oxoxDev
2026-06-23 11:53:25 -07:00
committed by GitHub
parent ae7dd39927
commit 494527b6ec
4 changed files with 107 additions and 0 deletions
@@ -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:<model-id>') 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]
@@ -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 `<slug>` provider string carries no
// model and the `cloud_providers` entry has no `default_model`:
// "[chat-factory] no model configured: role '<r>' resolved to an
// empty model id for slug '<s>'. Include a model in the provider
// string (e.g. '<s>:<model-id>') 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:<model-id>') 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
@@ -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 `<slug>` 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,
@@ -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]