From 35b29599d105359a7588bbcaf6312dd7fb2e9bb6 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 20:01:22 +0530 Subject: [PATCH 1/4] fix(providers): fall back to reasoning-v1 for unrecognized default_model (#2202) Stale `default_model` values written by older UI versions (e.g. `deepseek-v4-pro`, `claude-opus-4-7`) were forwarded verbatim to the OpenHuman backend, which rejects them with HTTP 400. This caused 88 Sentry events (TAURI-WJ + TAURI-QW). Changes: - Add `is_known_openhuman_tier(model)` helper in `factory.rs` that recognises the five canonical tier names plus `hint:*` prefixes. - In `make_openhuman_backend()`, replace the bare `_ => model` fall- through with a validated path: unknown tiers log a WARN and fall back to `MODEL_REASONING_V1`, matching the existing behaviour for an empty `default_model`. - In `config/ops.rs`, log a WARN when an unrecognised model is saved so the issue is surfaced at config-write time too. - Add 6 new unit tests in `factory_test.rs` covering the tier helper and the factory fallback. Closes #2202 --- src/openhuman/config/ops.rs | 11 ++++ src/openhuman/inference/provider/factory.rs | 42 +++++++++++- .../inference/provider/factory_test.rs | 64 +++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 644d9242b8..d9ce112d2f 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -437,6 +437,17 @@ pub async fn apply_model_settings( } else { Some(model) }; + if let Some(ref m) = config.default_model { + let trimmed = m.trim(); + if !crate::openhuman::inference::provider::factory::is_known_openhuman_tier(trimmed) { + log::warn!( + "[config][model-settings] default_model '{}' is not a recognized \ + OpenHuman backend tier — it will be replaced with the platform \ + default at inference time.", + trimmed + ); + } + } } if let Some(temp) = update.default_temperature { config.default_temperature = temp; diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 3ec225e30a..b36655f561 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -43,6 +43,27 @@ pub fn auth_key_for_slug(slug: &str) -> String { format!("provider:{slug}") } +/// Return whether `model` is a recognized OpenHuman backend tier name. +/// +/// Used to guard against stale `default_model` values (e.g. set by older UI +/// versions) that the backend would reject with HTTP 400. The known tiers are +/// the constants in `crate::openhuman::config`; `hint:*` prefixed strings are +/// also accepted because the factory translates them before sending. +pub(crate) fn is_known_openhuman_tier(model: &str) -> bool { + use crate::openhuman::config::{ + MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, + MODEL_REASONING_V1, + }; + matches!( + model, + MODEL_REASONING_V1 + | MODEL_CHAT_V1 + | MODEL_AGENTIC_V1 + | MODEL_CODING_V1 + | MODEL_REASONING_QUICK_V1 + ) || model.starts_with("hint:") +} + /// Return the configured provider string for a named workload role. /// /// Returns `"openhuman"` when the workload has no explicit override. @@ -218,13 +239,30 @@ fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box, options.secrets_encrypt ); // Translate `hint:` model strings into the OpenHuman backend's - // canonical tier names. + // canonical tier names. For any remaining string that is not a known + // tier, fall back to the default rather than forwarding it to the backend + // (which would return HTTP 400). This guards against stale `default_model` + // values written by older UI versions (e.g. "deepseek-v4-pro", + // "claude-opus-4-7") that persist across app updates. let model = match model.strip_prefix("hint:") { Some("reasoning") => crate::openhuman::config::MODEL_REASONING_V1.to_string(), Some("chat") => crate::openhuman::config::MODEL_CHAT_V1.to_string(), Some("agentic") => crate::openhuman::config::MODEL_AGENTIC_V1.to_string(), Some("coding") => crate::openhuman::config::MODEL_CODING_V1.to_string(), - _ => model, + _ => { + if is_known_openhuman_tier(&model) { + model + } else { + log::warn!( + "[providers][chat-factory] model '{}' is not a recognized OpenHuman \ + backend tier (valid: reasoning-v1, chat-v1, agentic-v1, coding-v1, \ + reasoning-quick-v1); falling back to '{}'", + model, + crate::openhuman::config::MODEL_REASONING_V1, + ); + crate::openhuman::config::MODEL_REASONING_V1.to_string() + } + } }; let p = Box::new(OpenHumanBackendProvider::new( config.api_url.as_deref(), diff --git a/src/openhuman/inference/provider/factory_test.rs b/src/openhuman/inference/provider/factory_test.rs index 61df1314c1..eb1c465a3c 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -454,3 +454,67 @@ fn verify_session_active_called_for_custom_provider_not_for_openhuman() { "verify_session_active must reject config without session", ); } + +// ── is_known_openhuman_tier ─────────────────────────────────────────────────── + +#[test] +fn known_tiers_pass() { + for tier in [ + "reasoning-v1", + "chat-v1", + "agentic-v1", + "coding-v1", + "reasoning-quick-v1", + ] { + assert!( + is_known_openhuman_tier(tier), + "expected tier '{tier}' to be recognized" + ); + } +} + +#[test] +fn hint_prefix_passes() { + assert!(is_known_openhuman_tier("hint:reasoning")); + assert!(is_known_openhuman_tier("hint:chat")); + assert!(is_known_openhuman_tier("hint:anything")); +} + +#[test] +fn invalid_models_fail() { + assert!(!is_known_openhuman_tier("deepseek-v4-pro")); + assert!(!is_known_openhuman_tier("claude-opus-4-7")); + assert!(!is_known_openhuman_tier("gpt-4o")); + assert!(!is_known_openhuman_tier("")); + assert!(!is_known_openhuman_tier("reasoning-v2")); +} + +#[test] +fn make_openhuman_backend_falls_back_for_invalid_model() { + // An invalid default_model must not be forwarded to the backend. + // The factory must silently fall back to reasoning-v1 (the platform default). + let mut config = Config::default(); + config.default_model = Some("deepseek-v4-pro".to_string()); + let (_, model) = make_openhuman_backend(&config).expect("factory should succeed"); + assert_eq!( + model, + crate::openhuman::config::MODEL_REASONING_V1, + "invalid default_model should fall back to MODEL_REASONING_V1" + ); +} + +#[test] +fn make_openhuman_backend_keeps_valid_tier() { + let mut config = Config::default(); + config.default_model = Some("chat-v1".to_string()); + let (_, model) = make_openhuman_backend(&config).expect("factory should succeed"); + assert_eq!(model, "chat-v1"); +} + +#[test] +fn make_openhuman_backend_keeps_reasoning_quick() { + let mut config = Config::default(); + config.default_model = Some("reasoning-quick-v1".to_string()); + let (_, model) = make_openhuman_backend(&config).expect("factory should succeed"); + assert_eq!(model, "reasoning-quick-v1"); +} From a037b50e73dd0611fadd063d80a3257cce496b61 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 22:00:31 +0530 Subject: [PATCH 2/4] test(providers): use recognized tier in factory-path assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `resolve_subagent_provider_hint_with_config_routes_via_factory` test used `"agentic-specific-model"` as the sentinel default_model, but the PR's `is_known_openhuman_tier` guard in `make_openhuman_backend` rejects unrecognized tier strings and falls back to `reasoning-v1`. Switch to `"coding-v1"` — a valid tier that the factory validation accepts and that differs from the old `agentic-v1` synthesis the test is guarding against. --- .../agent/harness/subagent_runner/ops_tests.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index cc9c765768..c107408267 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -907,11 +907,12 @@ fn resolve_subagent_provider_hint_with_config_routes_via_factory() { // `{workload}-v1` synthesis. use crate::openhuman::config::Config; let mut config = Config::default(); - // Route `agentic` to OpenHuman backend explicitly. The backend - // returns the configured default_model, which we set to a known - // string so the assertion is meaningful. + // Route `agentic` to OpenHuman backend explicitly. The backend returns + // the configured default_model. Use `coding-v1` — a recognized tier + // that the factory validation accepts and that differs from the old + // `agentic-v1` synthesis, making the assertion meaningful. config.agentic_provider = Some("openhuman".to_string()); - config.default_model = Some("agentic-specific-model".to_string()); + config.default_model = Some("coding-v1".to_string()); let parent: Arc = ScriptedProvider::new(vec![]); let (_resolved_provider, resolved_model) = super::resolve_subagent_provider( @@ -924,7 +925,7 @@ fn resolve_subagent_provider_hint_with_config_routes_via_factory() { None, ); assert_eq!( - resolved_model, "agentic-specific-model", + resolved_model, "coding-v1", "Hint must use the factory-resolved exact model, not synthesise `agentic-v1` \ and not fall back to parent's model" ); From fa92072dbed088d3572b4e50e7557a465982802f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 19 May 2026 14:15:01 -0700 Subject: [PATCH 3/4] fix(providers): reject unknown hint:* models in is_known_openhuman_tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous blanket `model.starts_with("hint:")` check accepted any `hint:*` string. Combined with the four-arm match in `make_openhuman_backend`, this meant values like `hint:garbage` or `hint:reasoning-quick` fell through to the `_ =>` arm where the tier check still returned true — so the untranslated `hint:*` string was forwarded to the backend, producing the same HTTP 400 this PR aims to prevent. Enumerate only the four hints the factory actually translates so unrecognized hints fall back to MODEL_REASONING_V1. Also normalize `default_model` at config-save time: persist the trimmed value rather than the raw input, so the persisted value and the validation check operate on the same string. Addresses @graycyrus inline review on factory.rs:64 and CodeRabbit's outside-diff finding on config/ops.rs:434. --- src/openhuman/config/ops.rs | 10 ++++---- src/openhuman/inference/provider/factory.rs | 13 ++++++++--- .../inference/provider/factory_test.rs | 23 +++++++++++++++++-- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index d9ce112d2f..e9162af9b3 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -432,19 +432,19 @@ pub async fn apply_model_settings( }; } if let Some(model) = update.default_model { - config.default_model = if model.trim().is_empty() { + let trimmed = model.trim(); + config.default_model = if trimmed.is_empty() { None } else { - Some(model) + Some(trimmed.to_string()) }; if let Some(ref m) = config.default_model { - let trimmed = m.trim(); - if !crate::openhuman::inference::provider::factory::is_known_openhuman_tier(trimmed) { + if !crate::openhuman::inference::provider::factory::is_known_openhuman_tier(m) { log::warn!( "[config][model-settings] default_model '{}' is not a recognized \ OpenHuman backend tier — it will be replaced with the platform \ default at inference time.", - trimmed + m ); } } diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index b36655f561..ea11bde254 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -47,8 +47,11 @@ pub fn auth_key_for_slug(slug: &str) -> String { /// /// Used to guard against stale `default_model` values (e.g. set by older UI /// versions) that the backend would reject with HTTP 400. The known tiers are -/// the constants in `crate::openhuman::config`; `hint:*` prefixed strings are -/// also accepted because the factory translates them before sending. +/// the constants in `crate::openhuman::config`; the four `hint:*` strings that +/// `make_openhuman_backend` actually translates are also accepted. An +/// unrecognized `hint:*` value is intentionally rejected so the factory falls +/// back to the platform default instead of forwarding an untranslated string +/// to the backend. pub(crate) fn is_known_openhuman_tier(model: &str) -> bool { use crate::openhuman::config::{ MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, @@ -61,7 +64,11 @@ pub(crate) fn is_known_openhuman_tier(model: &str) -> bool { | MODEL_AGENTIC_V1 | MODEL_CODING_V1 | MODEL_REASONING_QUICK_V1 - ) || model.starts_with("hint:") + | "hint:reasoning" + | "hint:chat" + | "hint:agentic" + | "hint:coding" + ) } /// Return the configured provider string for a named workload role. diff --git a/src/openhuman/inference/provider/factory_test.rs b/src/openhuman/inference/provider/factory_test.rs index eb1c465a3c..7b60d9b586 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -474,10 +474,11 @@ fn known_tiers_pass() { } #[test] -fn hint_prefix_passes() { +fn known_hints_pass() { assert!(is_known_openhuman_tier("hint:reasoning")); assert!(is_known_openhuman_tier("hint:chat")); - assert!(is_known_openhuman_tier("hint:anything")); + assert!(is_known_openhuman_tier("hint:agentic")); + assert!(is_known_openhuman_tier("hint:coding")); } #[test] @@ -487,6 +488,24 @@ fn invalid_models_fail() { assert!(!is_known_openhuman_tier("gpt-4o")); assert!(!is_known_openhuman_tier("")); assert!(!is_known_openhuman_tier("reasoning-v2")); + // Unrecognized `hint:*` values must NOT be accepted — the factory only + // translates the four hints above, so any other `hint:*` string would + // otherwise be forwarded to the backend and rejected with HTTP 400. + assert!(!is_known_openhuman_tier("hint:garbage")); + assert!(!is_known_openhuman_tier("hint:reasoning-quick")); + assert!(!is_known_openhuman_tier("hint:")); +} + +#[test] +fn make_openhuman_backend_falls_back_for_unknown_hint() { + let mut config = Config::default(); + config.default_model = Some("hint:garbage".to_string()); + let (_, model) = make_openhuman_backend(&config).expect("factory should succeed"); + assert_eq!( + model, + crate::openhuman::config::MODEL_REASONING_V1, + "unknown hint:* values must fall back to MODEL_REASONING_V1, not be forwarded" + ); } #[test] From bbbec08493c0005c50fdd0791e3044924f4ed4ee Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 20 May 2026 12:20:17 +0530 Subject: [PATCH 4/4] fix(providers): forward unknown hint:* verbatim, only fall back for stale non-hint models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (fa92072) was too aggressive: it enumerated only the four hints the factory translates (hint:reasoning/chat/agentic/coding) and fell back to reasoning-v1 for *any* other hint:* string. This broke the web-chat model_override path which is designed to forward lightweight hint strings like hint:reaction verbatim to the backend. Split the `_ =>` arm into explicit `Some(_)` and `None` branches: - Some(unrecognized suffix): forward the original hint:* string unchanged. The backend is authoritative over which hint values it accepts. - None (no hint: prefix): apply is_known_openhuman_tier() and fall back to reasoning-v1 for stale default_model values (deepseek-v4-pro, etc.). Update make_openhuman_backend_falls_back_for_unknown_hint → make_openhuman_backend_forwards_unknown_hint_verbatim to match the new semantics and verify hint:reaction, hint:garbage, hint:summarization all pass through unchanged. --- src/openhuman/inference/provider/factory.rs | 17 ++++++++++------ .../inference/provider/factory_test.rs | 20 ++++++++++--------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index ea11bde254..1b634de23b 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -246,17 +246,22 @@ fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box, options.secrets_encrypt ); // Translate `hint:` model strings into the OpenHuman backend's - // canonical tier names. For any remaining string that is not a known - // tier, fall back to the default rather than forwarding it to the backend - // (which would return HTTP 400). This guards against stale `default_model` - // values written by older UI versions (e.g. "deepseek-v4-pro", - // "claude-opus-4-7") that persist across app updates. + // canonical tier names. Unrecognised `hint:*` strings (e.g. `hint:reaction` + // for lightweight models) are forwarded as-is — the backend is authoritative + // over which hint values it accepts, and the web-chat model_override path + // uses these verbatim. Only non-hint strings that are not a known canonical + // tier (stale `default_model` values written by older UI versions, e.g. + // "deepseek-v4-pro", "claude-opus-4-7") fall back to the platform default. let model = match model.strip_prefix("hint:") { Some("reasoning") => crate::openhuman::config::MODEL_REASONING_V1.to_string(), Some("chat") => crate::openhuman::config::MODEL_CHAT_V1.to_string(), Some("agentic") => crate::openhuman::config::MODEL_AGENTIC_V1.to_string(), Some("coding") => crate::openhuman::config::MODEL_CODING_V1.to_string(), - _ => { + Some(_) => { + // Unrecognised hint — forward verbatim; the backend decides validity. + model + } + None => { if is_known_openhuman_tier(&model) { model } else { diff --git a/src/openhuman/inference/provider/factory_test.rs b/src/openhuman/inference/provider/factory_test.rs index 7b60d9b586..68872ed2ad 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -497,15 +497,17 @@ fn invalid_models_fail() { } #[test] -fn make_openhuman_backend_falls_back_for_unknown_hint() { - let mut config = Config::default(); - config.default_model = Some("hint:garbage".to_string()); - let (_, model) = make_openhuman_backend(&config).expect("factory should succeed"); - assert_eq!( - model, - crate::openhuman::config::MODEL_REASONING_V1, - "unknown hint:* values must fall back to MODEL_REASONING_V1, not be forwarded" - ); +fn make_openhuman_backend_forwards_unknown_hint_verbatim() { + // Unrecognised hint:* strings (e.g. hint:reaction for lightweight models) + // must be forwarded to the backend unchanged. The backend is authoritative + // over which hint values it accepts; the factory only translates the four + // canonical hints (reasoning/chat/agentic/coding). + for hint in ["hint:reaction", "hint:garbage", "hint:summarization"] { + let mut config = Config::default(); + config.default_model = Some(hint.to_string()); + let (_, model) = make_openhuman_backend(&config).expect("factory should succeed"); + assert_eq!(model, hint, "hint '{hint}' should pass through unchanged"); + } } #[test]