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
56 changes: 49 additions & 7 deletions src/openhuman/inference/provider/config_rejection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}`
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/openhuman/inference/provider/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}:<model-id>') or \
set default_model on the cloud_providers entry for slug '{slug}'.",
role,
Expand Down
51 changes: 51 additions & 0 deletions src/openhuman/inference/provider/factory_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading