diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index deb9896ff..809cfd244 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -963,11 +963,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( @@ -980,7 +981,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 5ea1549a6..380b697b0 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -458,11 +458,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 0bacc7576..3e961734a 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -59,6 +59,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. /// /// Empty / `"cloud"` resolves through `primary_cloud`. For backwards @@ -222,13 +250,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_REASONING_QUICK_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 7db20e557..97c380a02 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -604,3 +604,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"); +}