fix(inference): probe /v1/models for OpenAI-compatible local runtimes (Closes #5053) (#5067)

This commit is contained in:
Mega Mind
2026-07-20 21:39:15 +03:00
committed by GitHub
parent d3660b2684
commit 3ca9f87a30
3 changed files with 182 additions and 1 deletions
+107
View File
@@ -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
);
}
}
@@ -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
// `<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;
}
let healthy = self.ollama_healthy_at(&base_url).await;
let runner_ok = if healthy {
self.ollama_runner_ok_at(&base_url).await
@@ -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();