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
11 changes: 6 additions & 5 deletions src/openhuman/agent/harness/subagent_runner/ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Provider> = ScriptedProvider::new(vec![]);
let (_resolved_provider, resolved_model) = super::resolve_subagent_provider(
Expand All @@ -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"
);
Expand Down
15 changes: 13 additions & 2 deletions src/openhuman/config/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 52 additions & 2 deletions src/openhuman/inference/provider/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -218,13 +246,35 @@ fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box<dyn Provider>,
options.secrets_encrypt
);
// Translate `hint:<tier>` 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(),
Expand Down
85 changes: 85 additions & 0 deletions src/openhuman/inference/provider/factory_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Loading