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
107 changes: 107 additions & 0 deletions src/openhuman/inference/local/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand All @@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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
// `<base>/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;
Comment thread
M3gA-Mind marked this conversation as resolved.
}

let healthy = self.ollama_healthy_at(&base_url).await;
let runner_ok = if healthy {
self.ollama_runner_ok_at(&base_url).await
Expand Down
52 changes: 52 additions & 0 deletions src/openhuman/inference/local/service/ollama_admin_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading