mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(inference): synth local-runtime entry for list_models when no cloud_providers row (Sentry TAURI-RUST-28Z)
This commit is contained in:
@@ -106,7 +106,12 @@ impl Default for CloudProviderCreds {
|
||||
/// routing is unaffected — the `ollama:<model>` prefix branch in
|
||||
/// `factory::create_chat_provider_from_string` fires before the
|
||||
/// `<slug>:<model>` cloud-provider lookup, so a synthetic `ollama` entry
|
||||
/// never reaches `make_cloud_provider_by_slug`.
|
||||
/// never reaches `make_cloud_provider_by_slug`. When no `cloud_providers`
|
||||
/// row exists (config drift, upgrade from a build that only persisted
|
||||
/// `config.local_ai.base_url`, flush-vs-probe race),
|
||||
/// [`crate::openhuman::inference::provider::ops::list_configured_models`]
|
||||
/// falls back to a synthetic entry via `synthesize_local_runtime_entry`
|
||||
/// (Sentry TAURI-RUST-28Z fix). The same fallback applies to `lmstudio`.
|
||||
pub fn is_slug_reserved(s: &str) -> bool {
|
||||
matches!(s.trim(), "" | "cloud" | "openhuman" | "pid")
|
||||
}
|
||||
|
||||
@@ -46,11 +46,15 @@ async fn list_configured_models_from_config(
|
||||
|
||||
log::debug!("[providers][list_models] provider_id={}", provider_id);
|
||||
|
||||
// Explicit `cloud_providers` entry wins (e.g. a user-pointed remote
|
||||
// ollama box at https://ollama.example.com/v1). Falling back to the
|
||||
// local-runtime synthesis below only happens when no entry matches.
|
||||
let entry = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.id == provider_id || e.slug == provider_id)
|
||||
.cloned()
|
||||
.or_else(|| synthesize_local_runtime_entry(&provider_id, config))
|
||||
.ok_or_else(|| format!("no cloud provider with id or slug '{}' found", provider_id))?;
|
||||
|
||||
let base = entry.endpoint.trim_end_matches('/');
|
||||
@@ -274,6 +278,60 @@ fn json_value_kind(v: &serde_json::Value) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthesize a transient [`CloudProviderCreds`] entry for the well-known
|
||||
/// local-runtime slugs (`ollama`, `lmstudio`) so [`list_configured_models`]
|
||||
/// can probe their OpenAI-compatible `/v1/models` endpoint even when the
|
||||
/// user has not registered a matching `cloud_providers` row.
|
||||
///
|
||||
/// Background: the AI settings panel registers an `ollama` `cloud_providers`
|
||||
/// entry when the user configures Ollama (see comment on
|
||||
/// [`crate::openhuman::config::schema::cloud_providers::is_slug_reserved`]),
|
||||
/// but in practice some users hit
|
||||
/// `inference_list_models("ollama")` without that entry — config drift,
|
||||
/// flush-vs-probe race, or upgrade from a build that only persisted
|
||||
/// `config.local_ai.base_url`. Sentry TAURI-RUST-28Z captures this:
|
||||
/// 24 events / 7d, all `domain=rpc, method=openhuman.inference_list_models,
|
||||
/// operation=invoke_method`. Without this fallback, the dropdown surfaces
|
||||
/// the bare `"no cloud provider with id or slug 'ollama' found"` error
|
||||
/// (also visible in the Sentry breadcrumb) instead of returning models.
|
||||
///
|
||||
/// Returns `None` for any slug that is not a recognized local-runtime
|
||||
/// alias — callers continue down the normal "no cloud provider" error
|
||||
/// path for `openai` / `anthropic` / opaque ids / typos.
|
||||
fn synthesize_local_runtime_entry(
|
||||
slug: &str,
|
||||
config: &crate::openhuman::config::Config,
|
||||
) -> Option<crate::openhuman::config::schema::cloud_providers::CloudProviderCreds> {
|
||||
use crate::openhuman::config::schema::cloud_providers::{AuthStyle, CloudProviderCreds};
|
||||
|
||||
let endpoint = match slug {
|
||||
// Ollama's OpenAI-compatible surface at `<base>/v1/models` returns
|
||||
// the same `{"data": [...]}` shape the existing parser handles, so
|
||||
// we route through that rather than the native `/api/tags`.
|
||||
"ollama" => {
|
||||
let base = crate::openhuman::inference::local::ollama_base_url_from_config(config);
|
||||
format!("{}/v1", base.trim_end_matches('/'))
|
||||
}
|
||||
// `lm_studio_base_url` already ends in `/v1`.
|
||||
"lmstudio" => crate::openhuman::inference::local::lm_studio::lm_studio_base_url(config),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(CloudProviderCreds {
|
||||
id: format!("synthetic_local_{slug}"),
|
||||
slug: slug.to_string(),
|
||||
label: slug.to_string(),
|
||||
endpoint,
|
||||
// Local runtimes accept unauthenticated requests on loopback.
|
||||
// The probe at `<endpoint>/models` runs without an Authorization
|
||||
// header — `lookup_key_for_slug` may still return a key, but
|
||||
// `AuthStyle::None` ignores it (see auth-style match below).
|
||||
auth_style: AuthStyle::None,
|
||||
legacy_type: None,
|
||||
default_model: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_openrouter_provider(
|
||||
entry: &crate::openhuman::config::schema::cloud_providers::CloudProviderCreds,
|
||||
) -> bool {
|
||||
@@ -1958,6 +2016,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── synthesize_local_runtime_entry (TAURI-RUST-28Z fallback) ────────────
|
||||
|
||||
#[test]
|
||||
fn synthesize_local_runtime_entry_ollama_returns_v1_endpoint_with_no_auth() {
|
||||
// Sentry TAURI-RUST-28Z fires when `inference_list_models("ollama")`
|
||||
// runs against a config that has no `ollama` cloud_providers entry.
|
||||
// The synth fallback must produce an entry routed to Ollama's
|
||||
// OpenAI-compatible `/v1/models` surface at the resolved base URL,
|
||||
// with `AuthStyle::None` so the probe runs without an Authorization
|
||||
// header (loopback Ollama accepts unauthenticated requests).
|
||||
let config = Config::default();
|
||||
let entry = synthesize_local_runtime_entry("ollama", &config)
|
||||
.expect("ollama must produce a synthetic entry");
|
||||
assert_eq!(entry.slug, "ollama");
|
||||
assert_eq!(entry.auth_style, AuthStyle::None);
|
||||
assert!(
|
||||
entry.endpoint.ends_with("/v1"),
|
||||
"ollama endpoint must terminate at /v1 so `<endpoint>/models` hits the OpenAI-compat surface; got {}",
|
||||
entry.endpoint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_local_runtime_entry_lmstudio_returns_v1_endpoint_with_no_auth() {
|
||||
// LM Studio's default `lm_studio_base_url` already terminates at
|
||||
// `/v1`; the synth must preserve that and select `AuthStyle::None`
|
||||
// so the probe doesn't attach a bearer header (LM Studio runs
|
||||
// unauthenticated on loopback).
|
||||
let config = Config::default();
|
||||
let entry = synthesize_local_runtime_entry("lmstudio", &config)
|
||||
.expect("lmstudio must produce a synthetic entry");
|
||||
assert_eq!(entry.slug, "lmstudio");
|
||||
assert_eq!(entry.auth_style, AuthStyle::None);
|
||||
assert!(
|
||||
entry.endpoint.ends_with("/v1"),
|
||||
"lmstudio endpoint must terminate at /v1; got {}",
|
||||
entry.endpoint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_local_runtime_entry_returns_none_for_unknown_slug() {
|
||||
// Only `ollama` and `lmstudio` are the recognized local-runtime
|
||||
// aliases. Every other slug — built-in cloud providers (`openai`,
|
||||
// `anthropic`), opaque ids (`p_random_xyz`), or typos — must fall
|
||||
// through to the existing "no cloud provider" error. Pinning this
|
||||
// rejection contract guards against the synth growing into a
|
||||
// blanket "any unknown slug points at localhost" matcher.
|
||||
let config = Config::default();
|
||||
for slug in ["openai", "anthropic", "openrouter", "p_random_xyz", "", " "] {
|
||||
assert!(
|
||||
synthesize_local_runtime_entry(slug, &config).is_none(),
|
||||
"{slug:?} must NOT synthesize a local-runtime entry"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_models_response_handles_non_object_body() {
|
||||
// Provider returned a bare array / string / number at the
|
||||
@@ -2160,4 +2275,100 @@ mod tests {
|
||||
"raw secret must not survive into the SessionExpired reason: {reason}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_local_runtime_entry_ollama_respects_config_base_url() {
|
||||
// The synth must honor `config.local_ai.base_url` (the same
|
||||
// priority `ollama_base_url_from_config` uses for chat routing).
|
||||
// This is the path users hit when they point Ollama at a non-loopback
|
||||
// host (e.g. a LAN box at 192.168.1.5).
|
||||
let mut config = Config::default();
|
||||
config.local_ai.base_url = Some("http://192.168.1.5:11434".to_string());
|
||||
let entry = synthesize_local_runtime_entry("ollama", &config)
|
||||
.expect("ollama with custom base_url must still synthesize");
|
||||
assert_eq!(
|
||||
entry.endpoint, "http://192.168.1.5:11434/v1",
|
||||
"synth must use config.local_ai.base_url and append /v1 once",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_providers_entry_takes_precedence_over_local_runtime_synthesis() {
|
||||
// Pin the precedence: if the user has explicitly added an `ollama`
|
||||
// entry to `cloud_providers` (e.g. a remote ollama box at
|
||||
// https://ollama.example.com/v1), that entry MUST win — the synth
|
||||
// fallback is reached only when the find returns `None`. Mirrors
|
||||
// the lookup in `list_configured_models_from_config` so a future
|
||||
// refactor that swaps `find().or_else(synth)` for unconditional
|
||||
// synthesis fails this test loudly.
|
||||
let mut config = Config::default();
|
||||
config.cloud_providers.push(CloudProviderCreds {
|
||||
id: "p_ollama_explicit".to_string(),
|
||||
slug: "ollama".to_string(),
|
||||
label: "Remote Ollama".to_string(),
|
||||
endpoint: "https://ollama.example.com/v1".to_string(),
|
||||
auth_style: AuthStyle::Bearer,
|
||||
legacy_type: None,
|
||||
default_model: None,
|
||||
});
|
||||
|
||||
let resolved = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.id == "ollama" || e.slug == "ollama")
|
||||
.cloned()
|
||||
.or_else(|| synthesize_local_runtime_entry("ollama", &config))
|
||||
.expect("either explicit or synth must resolve");
|
||||
assert_eq!(
|
||||
resolved.endpoint, "https://ollama.example.com/v1",
|
||||
"explicit cloud_providers entry must beat local-runtime synth",
|
||||
);
|
||||
assert_eq!(resolved.auth_style, AuthStyle::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cloud_providers_entry_falls_back_to_local_runtime_synth() {
|
||||
// The TAURI-RUST-28Z regression contract: when no `ollama` entry
|
||||
// exists in `cloud_providers` AND the slug is a recognized
|
||||
// local-runtime alias, the find/synth chain must yield a synthetic
|
||||
// entry (instead of `None`, which produces the
|
||||
// "no cloud provider with id or slug 'ollama' found" Sentry error).
|
||||
let config = Config::default();
|
||||
assert!(
|
||||
config.cloud_providers.is_empty(),
|
||||
"precondition: clean config has no providers configured",
|
||||
);
|
||||
|
||||
let resolved = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.id == "ollama" || e.slug == "ollama")
|
||||
.cloned()
|
||||
.or_else(|| synthesize_local_runtime_entry("ollama", &config));
|
||||
assert!(
|
||||
resolved.is_some(),
|
||||
"ollama must resolve via synth when cloud_providers is empty"
|
||||
);
|
||||
assert_eq!(resolved.unwrap().slug, "ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cloud_providers_entry_for_unknown_slug_still_errors() {
|
||||
// The synth is intentionally narrow: only `ollama` and `lmstudio`
|
||||
// get fallback routing. An unknown slug with no `cloud_providers`
|
||||
// match must continue to produce `None` (which the caller surfaces
|
||||
// as the "no cloud provider" error) — otherwise typos would
|
||||
// silently route to localhost.
|
||||
let config = Config::default();
|
||||
let resolved = config
|
||||
.cloud_providers
|
||||
.iter()
|
||||
.find(|e| e.id == "tpyo" || e.slug == "tpyo")
|
||||
.cloned()
|
||||
.or_else(|| synthesize_local_runtime_entry("tpyo", &config));
|
||||
assert!(
|
||||
resolved.is_none(),
|
||||
"unknown slug with no cloud_providers entry must NOT synthesize",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user