mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user