diff --git a/src/openhuman/inference/provider/config_rejection.rs b/src/openhuman/inference/provider/config_rejection.rs index a2033237a..5bec74693 100644 --- a/src/openhuman/inference/provider/config_rejection.rs +++ b/src/openhuman/inference/provider/config_rejection.rs @@ -171,17 +171,44 @@ pub fn is_provider_config_rejection_message(body: &str) -> bool { // this is the `type` field used by litellm/Anthropic-style // envelopes for the same class of user-state error. "not_found_error", - // TAURI-RUST-4K7 — Ollama models that don't support tool calling - // (e.g. gemma3:1b-it-qat, huihui_ai/deepseek-r1-abliterated:8b) - // return HTTP 400 with one of these phrases. The compatible - // provider (`compatible.rs`) detects the error and retries - // without tools, so the 400 is expected capability-discovery - // rather than a product bug. Suppress Sentry to avoid noise from - // the first-attempt rejection that precedes the successful retry. + // TAURI-RUST-4NM — nvidia-nim (and some other providers) return + // `{"error":{"message":"model field is required","type":"invalid_request_error","code":"missing_required_field"}}` + // when the `model` key is absent or empty in the request body. + // This is a user-configuration error (provider string has no model + // component, e.g. `nvidia-nim:` with empty model), not a product + // regression. Demote from Sentry; the factory now validates this + // up-front so in practice this phrase should no longer appear. + "model field is required", + // TAURI-RUST-4XK — Ollama 403 when the requested model requires a + // paid Ollama subscription. Body carries the upgrade URL. User must + // switch to a free model or upgrade their Ollama account. + "requires a subscription", + // TAURI-RUST-2G / TAURI-RUST-2F — DeepSeek / compatible providers + // that use extended thinking reject tool-call turns when the + // `reasoning_content` block from a prior assistant turn is not + // threaded back. This is user-config state (model requires the + // caller to replay the thinking block; the frontend replay logic in + // `turn.rs` handles it for subsequent turns, so the first-turn 400 + // is expected capability-discovery, not a regression). + "in the thinking mode must be passed back", + // TAURI-RUST-35 / TAURI-RUST-4K7 / TAURI-RUST-4Z0 — Ollama models + // (e.g. gemma3, phi3, deepseek-r1) that do not support function + // calling return HTTP 400 with this phrase. The compatible provider + // retries without tools on 400, so the initial rejection is expected + // capability-discovery. Sentry noise suppressed here. "does not support tools", + // TAURI-RUST-4K7-d — alternative phrasing used by some Ollama model + // versions for the same tool-unsupported condition. "function calling is not supported", + // TAURI-RUST-4K7-e — litellm / OpenAI-compatible proxies reject the + // `tools` key in the request body when the backing model does not + // support tool use (e.g. local Ollama via LiteLLM gateway). "unknown parameter: tools", + // TAURI-RUST-4K7-f — Ollama native API surface rejects the field + // outright when the model has no function-calling capability. "unrecognized field `tools`", + // TAURI-RUST-4K7-g — another litellm / proxy variant of the same + // tool-unsupported condition. "unsupported parameter: tools", // TAURI-RUST-4NM — nvidia-nim (and compatible providers) return // `{"error":{"message":"model field is required","code":"missing_required_field"}}` @@ -565,6 +592,21 @@ mod tests { } } + #[test] + fn detects_nvidia_nim_missing_model_body() { + // TAURI-RUST-4NM — nvidia-nim rejects requests with model="" with + // `{"error":{"message":"model field is required",...}}`. + let body = r#"nvidia-nim API error (400 Bad Request): {"error":{"message":"model field is required","type":"invalid_request_error","code":"missing_required_field"}}"#; + assert!( + is_provider_config_rejection_message(body), + "TAURI-RUST-4NM body must classify as provider config-rejection: {body:?}" + ); + // Also verify the bare phrase on its own (defense-in-depth path). + assert!(is_provider_config_rejection_message( + "model field is required" + )); + } + #[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 55268f8d6..4a08122ac 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -788,7 +788,7 @@ fn make_cloud_provider_by_slug( slug, ); anyhow::bail!( - "[chat-factory] role '{}' resolved to an empty model id for slug '{}'. \ + "[chat-factory] no model configured: role '{}' resolved to an empty model id for slug '{}'. \ Include a model in the provider string (e.g. '{slug}:') or \ set default_model on the cloud_providers entry for slug '{slug}'.", role, diff --git a/src/openhuman/inference/provider/factory_test.rs b/src/openhuman/inference/provider/factory_test.rs index 71c6d9042..7366954ac 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -369,6 +369,57 @@ fn empty_model_in_ollama_rejected() { assert!(err.to_string().contains("empty model"), "{err}"); } +#[test] +fn cloud_provider_with_no_model_and_no_default_rejected() { + // TAURI-RUST-4NM — nvidia-nim (and others) reject `model=""` with + // "model field is required". The factory must catch this up-front with + // a clear, actionable message instead of leaking an empty model to the API. + let mut config = Config::default(); + config.cloud_providers.push(CloudProviderCreds { + id: "p_nim".to_string(), + slug: "nvidia-nim".to_string(), + label: "NVIDIA NIM".to_string(), + endpoint: "https://integrate.api.nvidia.com/v1".to_string(), + auth_style: AuthStyle::Bearer, + default_model: None, // no fallback model configured + ..Default::default() + }); + + let err = match create_chat_provider_from_string("reasoning", "nvidia-nim:", &config) { + Ok(_) => panic!("empty model must fail"), + Err(e) => e, + }; + let msg = err.to_string(); + assert!( + msg.contains("no model configured"), + "expected 'no model configured' in error, got: {msg}" + ); + assert!( + msg.contains("nvidia-nim"), + "error must name the slug; got: {msg}" + ); +} + +#[test] +fn cloud_provider_default_model_used_when_model_part_is_empty() { + // When provider string is "nvidia-nim:" (empty model) but the entry + // has a default_model, the factory must use the default — not error. + let mut config = Config::default(); + config.cloud_providers.push(CloudProviderCreds { + id: "p_nim".to_string(), + slug: "nvidia-nim".to_string(), + label: "NVIDIA NIM".to_string(), + endpoint: "https://integrate.api.nvidia.com/v1".to_string(), + auth_style: AuthStyle::Bearer, + default_model: Some("meta/llama-3.1-8b-instruct".to_string()), + ..Default::default() + }); + + let (_, model) = create_chat_provider_from_string("reasoning", "nvidia-nim:", &config) + .expect("empty model with default_model must succeed"); + assert_eq!(model, "meta/llama-3.1-8b-instruct"); +} + #[test] fn missing_slug_for_openai_gives_clear_error() { let config = Config::default();