fix: keep custom cloud provider as default (#2142)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Srinivas Vaddi
2026-05-19 20:51:58 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent ba40be1973
commit c07e8226fd
6 changed files with 370 additions and 37 deletions
+67 -13
View File
@@ -809,7 +809,7 @@ pub(super) fn redact_url_for_log(raw: &str) -> String {
/// untouched. Routing fields that already contain a `:` are assumed to be
/// in the new `<slug>:<model>` form.
fn migrate_cloud_provider_slugs(config: &mut Config) {
use super::cloud_providers::migrate_legacy_fields;
use super::cloud_providers::{migrate_legacy_fields, AuthStyle};
// Step 1: migrate every cloud_providers entry in-place.
for entry in &mut config.cloud_providers {
@@ -826,6 +826,23 @@ fn migrate_cloud_provider_slugs(config: &mut Config) {
.map(|e| (e.slug.clone(), e.id.clone()))
.collect();
let legacy_custom_slug = config
.inference_url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty() && !looks_like_openhuman_provider_endpoint(url))
.and_then(|url| {
let normalized = normalize_provider_endpoint(url);
config
.cloud_providers
.iter()
.find(|entry| {
!is_openhuman_provider_entry(entry)
&& normalize_provider_endpoint(&entry.endpoint) == normalized
})
.map(|entry| entry.slug.clone())
});
// Helper: rewrite a single routing field.
// Legacy bare strings are: "cloud", "openhuman", "openai", "anthropic",
// "openrouter", "custom" (no ':'). New strings contain ':'.
@@ -841,7 +858,10 @@ fn migrate_cloud_provider_slugs(config: &mut Config) {
match raw.as_str() {
"cloud" => {
// "cloud" sentinel: look for the primary or first non-openhuman entry.
// If none found, leave as "openhuman".
// If a legacy external inference_url exists and primary still points
// at OpenHuman, keep routing on that custom provider; that shape was
// written by older builds that preserved the endpoint but defaulted
// primary_cloud to OpenHuman.
let primary_slug = config.primary_cloud.as_deref().and_then(|pid| {
config
.cloud_providers
@@ -849,18 +869,29 @@ fn migrate_cloud_provider_slugs(config: &mut Config) {
.find(|e| e.id == pid)
.map(|e| e.slug.clone())
});
let slug = primary_slug.or_else(|| {
config
.cloud_providers
.iter()
.find(|e| e.slug != "openhuman")
.map(|e| e.slug.clone())
});
let slug = match primary_slug.as_deref() {
Some("openhuman") => legacy_custom_slug.clone().or(primary_slug),
Some(_) => primary_slug,
None => legacy_custom_slug.clone().or_else(|| {
config
.cloud_providers
.iter()
.find(|entry| !is_openhuman_provider_entry(entry))
.map(|entry| entry.slug.clone())
}),
};
if let Some(s) = slug {
tracing::info!(
"[config][migrate] rewriting routing 'cloud' → '{s}:' (empty model)"
);
*field = Some(format!("{s}:"));
if s == "openhuman" {
tracing::debug!(
"[config][migrate] rewriting routing 'cloud' → 'openhuman'"
);
*field = Some("openhuman".to_string());
} else {
tracing::info!(
"[config][migrate] rewriting routing 'cloud' → '{s}:' (empty model)"
);
*field = Some(format!("{s}:"));
}
} else {
tracing::debug!(
"[config][migrate] routing 'cloud' with no non-openhuman provider → 'openhuman'"
@@ -897,6 +928,29 @@ fn migrate_cloud_provider_slugs(config: &mut Config) {
rewrite(&mut config.heartbeat_provider);
rewrite(&mut config.learning_provider);
rewrite(&mut config.subconscious_provider);
fn normalize_provider_endpoint(url: &str) -> String {
url.trim().trim_end_matches('/').to_ascii_lowercase()
}
fn looks_like_openhuman_provider_endpoint(url: &str) -> bool {
let lower = url.trim().to_ascii_lowercase();
let without_scheme = lower.split("://").nth(1).unwrap_or(&lower);
let authority = without_scheme.split('/').next().unwrap_or("");
let host = authority.split('@').next_back().unwrap_or(authority);
let host_no_port = host.split(':').next().unwrap_or(host);
matches!(
host_no_port,
"api.openhuman.ai" | "api.tinyhumans.ai" | "staging-api.tinyhumans.ai" | "openhuman"
) || host_no_port.ends_with(".openhuman.ai")
|| host_no_port.ends_with(".tinyhumans.ai")
}
fn is_openhuman_provider_entry(entry: &super::cloud_providers::CloudProviderCreds) -> bool {
entry.slug == "openhuman"
|| matches!(entry.auth_style, AuthStyle::OpenhumanJwt)
|| looks_like_openhuman_provider_endpoint(&entry.endpoint)
}
}
fn migrate_legacy_autocomplete_disabled_apps(config: &mut Config) {
+86
View File
@@ -1407,6 +1407,92 @@ fn migrate_legacy_inference_url_is_noop_when_inference_url_set() {
);
}
#[test]
fn migrate_cloud_provider_slugs_routes_cloud_to_legacy_custom_when_primary_is_openhuman() {
let mut cfg = Config::default();
cfg.inference_url = Some("https://api.example.com/v1".into());
cfg.primary_cloud = Some("p_oh".into());
cfg.memory_provider = Some("cloud".into());
cfg.reasoning_provider = Some("openhuman".into());
cfg.cloud_providers = vec![
crate::openhuman::config::schema::CloudProviderCreds {
id: "p_oh".into(),
slug: "openhuman".into(),
label: "OpenHuman".into(),
endpoint: "https://api.openhuman.ai/v1".into(),
auth_style: crate::openhuman::config::schema::AuthStyle::OpenhumanJwt,
..Default::default()
},
crate::openhuman::config::schema::CloudProviderCreds {
id: "p_custom".into(),
slug: "custom".into(),
label: "Custom".into(),
endpoint: "https://api.example.com/v1/".into(),
auth_style: crate::openhuman::config::schema::AuthStyle::Bearer,
default_model: Some("gpt-4o-mini".into()),
..Default::default()
},
];
migrate_cloud_provider_slugs(&mut cfg);
assert_eq!(cfg.memory_provider.as_deref(), Some("custom:"));
assert_eq!(
cfg.reasoning_provider.as_deref(),
Some("openhuman"),
"explicit OpenHuman routing must stay explicit"
);
}
#[test]
fn migrate_cloud_provider_slugs_keeps_cloud_on_openhuman_without_legacy_custom() {
let mut cfg = Config::default();
cfg.primary_cloud = Some("p_oh".into());
cfg.memory_provider = Some("cloud".into());
cfg.cloud_providers = vec![crate::openhuman::config::schema::CloudProviderCreds {
id: "p_oh".into(),
slug: "openhuman".into(),
label: "OpenHuman".into(),
endpoint: "https://api.tinyhumans.ai/v1".into(),
auth_style: crate::openhuman::config::schema::AuthStyle::OpenhumanJwt,
..Default::default()
}];
migrate_cloud_provider_slugs(&mut cfg);
assert_eq!(cfg.memory_provider.as_deref(), Some("openhuman"));
}
#[test]
fn migrate_cloud_provider_slugs_does_not_pick_unmatched_custom_provider() {
let mut cfg = Config::default();
cfg.inference_url = Some("https://api.example.com/v1".into());
cfg.primary_cloud = Some("p_oh".into());
cfg.memory_provider = Some("cloud".into());
cfg.cloud_providers = vec![
crate::openhuman::config::schema::CloudProviderCreds {
id: "p_oh".into(),
slug: "openhuman".into(),
label: "OpenHuman".into(),
endpoint: "https://api.openhuman.ai/v1".into(),
auth_style: crate::openhuman::config::schema::AuthStyle::OpenhumanJwt,
..Default::default()
},
crate::openhuman::config::schema::CloudProviderCreds {
id: "p_other".into(),
slug: "other".into(),
label: "Other".into(),
endpoint: "https://other.example.com/v1".into(),
auth_style: crate::openhuman::config::schema::AuthStyle::Bearer,
..Default::default()
},
];
migrate_cloud_provider_slugs(&mut cfg);
assert_eq!(cfg.memory_provider.as_deref(), Some("openhuman"));
}
/// Regression test for #1900: secrets are encrypted on save and decrypted on load.
///
/// Verifies that:
+103 -22
View File
@@ -8,11 +8,12 @@
//!
//! ```text
//! "openhuman" → OpenHumanBackendProvider; model = config.default_model
//! "cloud" / missing → primary_cloud; legacy custom inference_url wins when
//! primary still points at OpenHuman after migration
//! "ollama:<model>[@<temp>]" → local Ollama at config.local_ai.base_url
//! "<slug>:<model>[@<temp>]" → cloud_providers entry keyed by slug;
//! builds OpenAiCompatibleProvider (Bearer) or
//! Anthropic flavour depending on auth_style.
//! "" / missing → falls back to "openhuman"
//! ```
//!
//! The optional `@<temp>` suffix pins a per-workload temperature override on
@@ -45,7 +46,11 @@ pub fn auth_key_for_slug(slug: &str) -> String {
/// Return the configured provider string for a named workload role.
///
/// Returns `"openhuman"` when the workload has no explicit override.
/// Empty / `"cloud"` resolves through `primary_cloud`. For backwards
/// compatibility, a legacy external `inference_url` takes precedence when
/// `primary_cloud` still points at OpenHuman because migration 1→2 preserved
/// the URL as a custom provider entry but older configs did not explicitly set
/// per-workload routes.
pub fn provider_for_role(role: &str, config: &Config) -> String {
let opt = match role {
"chat" => config.chat_provider.as_deref(),
@@ -66,24 +71,7 @@ pub fn provider_for_role(role: &str, config: &Config) -> String {
};
let s = opt.unwrap_or("").trim();
if s.is_empty() || s == "cloud" {
// When no explicit per-workload provider is set, resolve
// primary_cloud. If it points to a non-openhuman entry, route
// there so users can use their own LLM provider without having
// to set every single workload knob. (An active app-session
// is still required — verified inside
// create_chat_provider_from_string.)
let primary_slug = config.primary_cloud.as_deref().and_then(|pid| {
config
.cloud_providers
.iter()
.find(|e| e.id == pid && e.slug != "openhuman")
.map(|e| e.slug.clone())
});
if let Some(slug) = primary_slug {
format!("{slug}:")
} else {
PROVIDER_OPENHUMAN.to_string()
}
resolve_primary_cloud_provider_string(config)
} else {
s.to_string()
}
@@ -118,9 +106,10 @@ pub fn create_chat_provider_from_string(
p
);
// Empty / legacy "cloud" sentinel → OpenHuman backend.
// Empty / legacy "cloud" sentinel → primary cloud target.
if p.is_empty() || p == "cloud" {
return make_openhuman_backend(config);
let resolved = resolve_primary_cloud_provider_string(config);
return create_chat_provider_from_string(role, &resolved, config);
}
if p == PROVIDER_OPENHUMAN {
@@ -268,6 +257,98 @@ fn verify_session_active(config: &Config) -> anyhow::Result<()> {
Ok(())
}
fn resolve_primary_cloud_provider_string(config: &Config) -> String {
let primary = config
.primary_cloud
.as_deref()
.and_then(|id| config.cloud_providers.iter().find(|entry| entry.id == id));
if primary.is_some_and(is_openhuman_cloud_entry) {
if let Some(legacy) = legacy_custom_inference_provider_string(config) {
return legacy;
}
}
if let Some(entry) = primary {
return cloud_entry_provider_string(entry, config);
}
legacy_custom_inference_provider_string(config)
.unwrap_or_else(|| PROVIDER_OPENHUMAN.to_string())
}
fn legacy_custom_inference_provider_string(config: &Config) -> Option<String> {
let inference_url = config
.inference_url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty())?;
if looks_like_openhuman_backend(inference_url) {
return None;
}
let normalized_inference = normalize_endpoint_for_compare(inference_url);
config
.cloud_providers
.iter()
.find(|entry| {
!is_openhuman_cloud_entry(entry)
&& normalize_endpoint_for_compare(&entry.endpoint) == normalized_inference
})
.map(|entry| cloud_entry_provider_string(entry, config))
}
fn cloud_entry_provider_string(
entry: &crate::openhuman::config::schema::cloud_providers::CloudProviderCreds,
config: &Config,
) -> String {
if is_openhuman_cloud_entry(entry) {
return PROVIDER_OPENHUMAN.to_string();
}
let model = entry
.default_model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
.or_else(|| {
config
.default_model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
})
.unwrap_or(crate::openhuman::config::DEFAULT_MODEL);
format!("{}:{model}", entry.slug)
}
fn is_openhuman_cloud_entry(
entry: &crate::openhuman::config::schema::cloud_providers::CloudProviderCreds,
) -> bool {
entry.slug == PROVIDER_OPENHUMAN
|| matches!(entry.auth_style, AuthStyle::OpenhumanJwt)
|| looks_like_openhuman_backend(&entry.endpoint)
}
fn normalize_endpoint_for_compare(url: &str) -> String {
url.trim().trim_end_matches('/').to_ascii_lowercase()
}
fn looks_like_openhuman_backend(url: &str) -> bool {
let lower = url.trim().to_ascii_lowercase();
let without_scheme = lower.split("://").nth(1).unwrap_or(&lower);
let authority = without_scheme.split('/').next().unwrap_or("");
let host = authority.split('@').next_back().unwrap_or(authority);
let host_no_port = host.split(':').next().unwrap_or(host);
matches!(
host_no_port,
"api.openhuman.ai" | "api.tinyhumans.ai" | "staging-api.tinyhumans.ai" | "openhuman"
) || host_no_port.ends_with(".openhuman.ai")
|| host_no_port.ends_with(".tinyhumans.ai")
}
/// Parse a `<model>[@<temp>]` tail into `(model, override)`.
///
/// Tolerates whitespace around the components. Returns `temperature = None`
@@ -71,6 +71,16 @@ fn cloud_no_providers_falls_back_to_openhuman() {
);
}
#[test]
fn direct_cloud_sentinel_resolves_to_primary_custom_provider() {
let mut config = config_with_providers(vec![oh_entry("p_oh"), openai_entry("p_oai", "openai")]);
config.primary_cloud = Some("p_oai".to_string());
let (_, model) =
create_chat_provider_from_string("reasoning", "cloud", &config).expect("build");
assert_eq!(model, "gpt-4o");
}
#[test]
fn openhuman_slug_routes_to_backend() {
let config = config_with_providers(vec![oh_entry("p_oh")]);
@@ -377,6 +387,71 @@ fn primary_cloud_defaults_to_openhuman_when_no_providers() {
assert!(create_chat_provider("reasoning", &config).is_ok());
}
#[test]
fn cloud_sentinel_resolves_to_primary_custom_provider() {
let mut config = config_with_providers(vec![oh_entry("p_oh"), openai_entry("p_oai", "openai")]);
config.primary_cloud = Some("p_oai".to_string());
assert_eq!(provider_for_role("reasoning", &config), "openai:gpt-4o");
let (_, model) =
create_chat_provider("reasoning", &config).expect("primary custom provider must build");
assert_eq!(model, "gpt-4o");
}
#[test]
fn legacy_inference_url_custom_provider_wins_over_openhuman_primary_for_unset_role() {
let mut custom = openai_entry("p_custom", "custom");
custom.endpoint = "https://api.example.com/v1/".to_string();
custom.default_model = Some("gpt-4o-mini".to_string());
let mut config = config_with_providers(vec![oh_entry("p_oh"), custom]);
config.primary_cloud = Some("p_oh".to_string());
config.inference_url = Some("https://api.example.com/v1".to_string());
assert_eq!(
provider_for_role("reasoning", &config),
"custom:gpt-4o-mini"
);
}
#[test]
fn legacy_inference_url_without_matching_provider_stays_on_openhuman_primary() {
let mut other = openai_entry("p_other", "other");
other.endpoint = "https://other.example.com/v1".to_string();
let mut config = config_with_providers(vec![oh_entry("p_oh"), other]);
config.primary_cloud = Some("p_oh".to_string());
config.inference_url = Some("https://api.example.com/v1".to_string());
assert_eq!(provider_for_role("reasoning", &config), "openhuman");
}
#[test]
fn hosted_endpoint_entry_is_treated_as_openhuman_backend() {
let mut hosted = openai_entry("p_hosted", "custom-hosted");
hosted.endpoint = "https://staging-api.tinyhumans.ai/openai/v1".to_string();
hosted.auth_style = AuthStyle::Bearer;
let mut config = config_with_providers(vec![hosted]);
config.primary_cloud = Some("p_hosted".to_string());
assert_eq!(provider_for_role("reasoning", &config), "openhuman");
}
#[test]
fn explicit_openhuman_route_ignores_legacy_inference_url() {
let mut custom = openai_entry("p_custom", "custom");
custom.endpoint = "https://api.example.com/v1".to_string();
let mut config = config_with_providers(vec![oh_entry("p_oh"), custom]);
config.primary_cloud = Some("p_oh".to_string());
config.inference_url = Some("https://api.example.com/v1".to_string());
config.reasoning_provider = Some("openhuman".to_string());
assert_eq!(provider_for_role("reasoning", &config), "openhuman");
}
#[test]
fn summarization_aliases_memory_provider() {
let mut config = Config::default();
@@ -143,12 +143,41 @@ fn seed_cloud_providers(config: &mut Config, stats: &mut MigrationStats) {
}
}
/// Default `primary_cloud` to the OpenHuman entry (the first one we just
/// seeded, by construction). Idempotent — only sets if currently `None`.
/// Default `primary_cloud` to the legacy custom inference entry when one was
/// present; otherwise use the OpenHuman entry. This preserves pre-migration
/// behavior where a configured `inference_url` handled chat instead of the
/// hosted OpenHuman budget.
fn set_primary_cloud(config: &mut Config, stats: &mut MigrationStats) {
if config.primary_cloud.is_some() {
return;
}
let legacy_custom = config
.inference_url
.as_deref()
.map(str::trim)
.filter(|url| !url.is_empty() && !looks_like_openhuman(url))
.and_then(|url| {
let normalized = url.trim_end_matches('/').to_ascii_lowercase();
config.cloud_providers.iter().find(|entry| {
entry.slug != "openhuman"
&& entry
.endpoint
.trim()
.trim_end_matches('/')
.to_ascii_lowercase()
== normalized
})
});
if let Some(entry) = legacy_custom {
config.primary_cloud = Some(entry.id.clone());
stats.primary_cloud_set = true;
log::debug!(
"[migrations][unify-ai] primary_cloud set to legacy custom entry id={}",
entry.id
);
return;
}
let oh = config
.cloud_providers
.iter()
@@ -248,6 +277,9 @@ fn looks_like_openhuman(url: &str) -> bool {
let host_no_port = host.split(':').next().unwrap_or(host);
host_no_port == "api.openhuman.ai"
|| host_no_port.ends_with(".openhuman.ai")
|| host_no_port == "api.tinyhumans.ai"
|| host_no_port == "staging-api.tinyhumans.ai"
|| host_no_port.ends_with(".tinyhumans.ai")
// Allow bare "openhuman" for local/dev names (e.g. Docker compose service names).
|| host_no_port == "openhuman"
}
@@ -62,6 +62,11 @@ fn legacy_inference_url_becomes_custom_entry() {
.expect("custom entry must be seeded");
assert_eq!(custom.endpoint, "https://api.example.com/v1");
assert_eq!(custom.default_model.as_deref(), Some("gpt-4o"));
assert_eq!(
c.primary_cloud.as_deref(),
Some(custom.id.as_str()),
"legacy custom inference must remain the default cloud target after migration"
);
}
#[test]