diff --git a/src/openhuman/inference/local/provider.rs b/src/openhuman/inference/local/provider.rs index e869ac8b73..b69889d030 100644 --- a/src/openhuman/inference/local/provider.rs +++ b/src/openhuman/inference/local/provider.rs @@ -48,6 +48,64 @@ pub(crate) fn provider_from_config(config: &Config) -> LocalAiProvider { } } +/// How a local runtime exposes its installed-model catalog. +/// +/// Ollama serves a native `GET /api/tags` listing; every OpenAI-compatible +/// runtime (LM Studio, OMLX, `local-openai`, and any custom BYOK endpoint that +/// speaks the OpenAI `/v1` surface) serves `GET /v1/models`. Sending an Ollama +/// probe to an OpenAI-compatible server produces `GET /v1/api/tags`, which +/// LM Studio logs as `Unexpected endpoint or method` and answers with an empty +/// catalog — so model discovery silently fails and the model never appears as +/// selectable (GH #5053). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ModelDiscoveryApi { + /// Ollama-native `/api/tags`. + OllamaTags, + /// OpenAI-compatible `/v1/models`. + OpenAiModels, +} + +/// True when `base_url`'s path is the OpenAI-compatible `/v1` root — the +/// canonical marker of an OpenAI-style server (LM Studio, OMLX, LiteLLM, …). +/// +/// A genuine Ollama base URL is host-rooted (`http://localhost:11434`) with no +/// `/v1` path segment, so this cleanly separates the two API shapes by +/// **endpoint type**, not by "is it localhost" (which would misidentify a +/// localhost LM Studio server as Ollama — the #5053 conflation). +pub(crate) fn endpoint_is_openai_v1(base_url: &str) -> bool { + if let Ok(url) = reqwest::Url::parse(base_url.trim()) { + let path = url.path().trim_end_matches('/').to_ascii_lowercase(); + return path.ends_with("/v1"); + } + // Fall back to a lexical check when the URL doesn't parse cleanly. + base_url + .trim() + .trim_end_matches('/') + .to_ascii_lowercase() + .ends_with("/v1") +} + +/// Select the model-discovery API for a local runtime from its provider slug +/// and base URL — by provider **type**, never by "is it localhost". +/// +/// Genuine Ollama (`provider = "ollama"` on a host-rooted base) uses +/// `/api/tags`. Every OpenAI-compatible runtime uses `/v1/models`: an explicit +/// `lm_studio` / `omlx` slug, OR any endpoint whose path is the OpenAI `/v1` +/// root. The `/v1` endpoint clause is what rescues a custom BYOK localhost +/// endpoint (e.g. LM Studio on `http://localhost:1234/v1`) whose provider tag +/// still defaults to `ollama`: the endpoint type wins over the fallback slug +/// (GH #5053). +pub(crate) fn model_discovery_api(provider: &str, base_url: &str) -> ModelDiscoveryApi { + if endpoint_is_openai_v1(base_url) { + return ModelDiscoveryApi::OpenAiModels; + } + match normalize_provider(provider).as_str() { + "ollama" => ModelDiscoveryApi::OllamaTags, + // lm_studio, omlx, and any other OpenAI-compatible local runtime. + _ => ModelDiscoveryApi::OpenAiModels, + } +} + #[cfg(test)] mod tests { use super::*; @@ -71,4 +129,53 @@ mod tests { assert_eq!(normalize_provider("omlx-server"), "omlx"); assert_eq!(normalize_provider("OMLX"), "omlx"); } + + #[test] + fn endpoint_is_openai_v1_detects_v1_root() { + assert!(endpoint_is_openai_v1("http://localhost:1234/v1")); + assert!(endpoint_is_openai_v1("http://localhost:1234/v1/")); + assert!(endpoint_is_openai_v1("https://box.local:1234/openai/v1")); + // Genuine Ollama base is host-rooted with no /v1 path. + assert!(!endpoint_is_openai_v1("http://localhost:11434")); + assert!(!endpoint_is_openai_v1("http://localhost:11434/")); + // A `/v1` embedded mid-path is not the OpenAI root. + assert!(!endpoint_is_openai_v1("http://localhost:11434/v1/models")); + } + + #[test] + fn model_discovery_api_uses_tags_for_genuine_ollama() { + // Ollama slug on its host-rooted native base -> /api/tags. + assert_eq!( + model_discovery_api("ollama", "http://localhost:11434"), + ModelDiscoveryApi::OllamaTags + ); + assert_eq!( + model_discovery_api("", "http://localhost:11434"), + ModelDiscoveryApi::OllamaTags + ); + } + + #[test] + fn model_discovery_api_uses_v1_models_for_openai_compatible() { + // Explicit LM Studio / OMLX slugs are OpenAI-compatible. + assert_eq!( + model_discovery_api("lm_studio", "http://localhost:1234/v1"), + ModelDiscoveryApi::OpenAiModels + ); + assert_eq!( + model_discovery_api("omlx", "http://localhost:8080/v1"), + ModelDiscoveryApi::OpenAiModels + ); + // The #5053 case: a custom BYOK OpenAI-compatible endpoint on localhost + // whose provider tag still defaults to `ollama` must NOT be probed with + // /api/tags — the `/v1` endpoint type wins. + assert_eq!( + model_discovery_api("ollama", "http://localhost:1234/v1"), + ModelDiscoveryApi::OpenAiModels + ); + assert_eq!( + model_discovery_api("custom-byok", "http://localhost:1234/v1"), + ModelDiscoveryApi::OpenAiModels + ); + } } diff --git a/src/openhuman/inference/local/service/ollama_admin/diagnostics.rs b/src/openhuman/inference/local/service/ollama_admin/diagnostics.rs index 2f5b7e9bef..8ba88381a2 100644 --- a/src/openhuman/inference/local/service/ollama_admin/diagnostics.rs +++ b/src/openhuman/inference/local/service/ollama_admin/diagnostics.rs @@ -7,7 +7,9 @@ use crate::openhuman::inference::local::ollama::{ ollama_base_url_from_config, OllamaModelShow, OllamaModelTag, OllamaShowRequest, OllamaShowResponse, OllamaTagsResponse, }; -use crate::openhuman::inference::local::provider::{provider_from_config, LocalAiProvider}; +use crate::openhuman::inference::local::provider::{ + model_discovery_api, provider_from_config, LocalAiProvider, ModelDiscoveryApi, +}; use crate::openhuman::inference::model_ids; use crate::openhuman::inference::presets::{self, VisionMode}; @@ -23,6 +25,26 @@ impl LocalAiService { } let base_url = ollama_base_url_from_config(config); + + // Route OpenAI-compatible local runtimes to the `/v1/models` probe. + // `provider_from_config` collapses everything that is not literally + // `lm_studio` onto `Ollama`, so an OMLX / `local-openai` / custom BYOK + // endpoint on the OpenAI `/v1` surface (e.g. LM Studio at + // `http://localhost:1234/v1`) would otherwise be probed at + // `/api/tags` -> `GET /v1/api/tags`, which LM Studio rejects as an + // unexpected endpoint and answers with an empty catalog — silently + // breaking model discovery (GH #5053). Decide by endpoint *type*, never + // by "is it localhost". + if model_discovery_api(&config.local_ai.provider, &base_url) + == ModelDiscoveryApi::OpenAiModels + { + log::debug!( + "[local_ai] diagnostics: base_url={} is OpenAI-compatible (/v1) — routing to /v1/models discovery instead of Ollama /api/tags", + base_url + ); + return self.lm_studio_diagnostics(config).await; + } + let healthy = self.ollama_healthy_at(&base_url).await; let runner_ok = if healthy { self.ollama_runner_ok_at(&base_url).await diff --git a/src/openhuman/inference/local/service/ollama_admin_tests.rs b/src/openhuman/inference/local/service/ollama_admin_tests.rs index 8637c20839..fb0ff10787 100644 --- a/src/openhuman/inference/local/service/ollama_admin_tests.rs +++ b/src/openhuman/inference/local/service/ollama_admin_tests.rs @@ -757,6 +757,58 @@ async fn lm_studio_diagnostics_reports_loaded_chat_model() { assert_eq!(diag["ok"], true); } +/// Regression for GH #5053: a custom OpenAI-compatible BYOK endpoint on +/// localhost (e.g. LM Studio at `http://localhost:1234/v1`) whose `provider` +/// tag still defaults to `ollama` must be probed with `/v1/models`, NOT the +/// Ollama-native `/api/tags`. The mock serves ONLY `/v1/models` and no +/// `/api/tags`, so before the fix diagnostics took the Ollama branch, +/// hit an unrouted `/v1/api/tags`, and reported the model absent; after the +/// fix the `/v1` endpoint type routes discovery to `/v1/models` and the model +/// is found. +#[tokio::test] +async fn diagnostics_openai_compatible_v1_endpoint_uses_v1_models_not_api_tags() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + // OpenAI-compatible server: exposes `/v1/models` and deliberately no + // `/api/tags` — an Ollama probe here would 404 (silently empty discovery). + let app = Router::new().route( + "/v1/models", + get(|| async { + Json(json!({ + "data": [ + { "id": "local-model", "object": "model", "owned_by": "lm-studio" } + ] + })) + }), + ); + let base = spawn_mock(app).await; + + // The #5053 config: a `/v1` OpenAI-compatible endpoint whose provider tag is + // the defaulted `ollama` (not `lm_studio`). + let mut config = lm_studio_config(&base); + config.local_ai.provider = "ollama".to_string(); + + let service = LocalAiService::new(&config); + let diag = service.diagnostics(&config).await.expect("diagnostics"); + + // `lm_studio_running` is emitted only by the OpenAI-compatible (`/v1/models`) + // diagnostics path — the Ollama branch reports `ollama_running` and leaves + // this key null. Its presence proves discovery was routed by endpoint type, + // not sent to `/api/tags`. + assert_eq!(diag["lm_studio_running"], true); + let installed = diag["installed_models"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + installed + .iter() + .any(|m| m["name"].as_str() == Some("local-model")), + "OpenAI-compatible /v1 endpoint must discover models via /v1/models, got: {:?}", + installed + ); +} + #[tokio::test] async fn lm_studio_diagnostics_flags_missing_chat_model() { let _guard = crate::openhuman::inference::inference_test_guard();