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" ); diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 644d9242b8..e9162af9b3 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -432,11 +432,22 @@ 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 { + 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.", + m + ); + } + } } 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..1b634de23b 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -43,6 +43,34 @@ 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`; 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, + MODEL_REASONING_V1, + }; + matches!( + model, + MODEL_REASONING_V1 + | MODEL_CHAT_V1 + | MODEL_AGENTIC_V1 + | MODEL_CODING_V1 + | MODEL_REASONING_QUICK_V1 + | "hint:reasoning" + | "hint:chat" + | "hint:agentic" + | "hint:coding" + ) +} + /// Return the configured provider string for a named workload role. /// /// Returns `"openhuman"` when the workload has no explicit override. @@ -218,13 +246,35 @@ 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. 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(), - _ => model, + Some(_) => { + // Unrecognised hint — forward verbatim; the backend decides validity. + model + } + None => { + 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..68872ed2ad 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -454,3 +454,88 @@ 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 known_hints_pass() { + assert!(is_known_openhuman_tier("hint:reasoning")); + assert!(is_known_openhuman_tier("hint:chat")); + assert!(is_known_openhuman_tier("hint:agentic")); + assert!(is_known_openhuman_tier("hint:coding")); +} + +#[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")); + // 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_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] +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"); +}