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
54 changes: 51 additions & 3 deletions src/openhuman/inference/local/lm_studio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,28 @@ pub(crate) struct LmStudioNativeModelsResponse {
/// Map a normalized `…/v1` base URL to the LM Studio native models endpoint
/// `…/api/v0/models` (a sibling of `/v1`, served at the host root).
pub(crate) fn lm_studio_native_models_url(v1_base_url: &str) -> String {
let root = v1_base_url
format!("{}/api/v0/models", host_root_of(v1_base_url))
}

/// Strip a trailing `/v1` so sibling endpoints served at the host root can be
/// derived from an OpenAI-compatible base URL.
pub(crate) fn host_root_of(v1_base_url: &str) -> &str {
v1_base_url
.trim_end_matches('/')
.trim_end_matches("/v1")
.trim_end_matches('/');
format!("{root}/api/v0/models")
.trim_end_matches('/')
}

/// Ollama-native `GET /api/tags` URL derived from an OpenAI-compatible base.
///
/// Only used as the one-shot 404 fallback in
/// [`LocalAiService::list_lm_studio_models`](crate::openhuman::inference::local::service::LocalAiService):
/// some runtimes are reachable on an OpenAI-shaped base URL but expose only the
/// Ollama listing (e.g. plain Ollama configured with a `/v1` base). Discovery is
/// still chosen by provider type first — this is a recovery path, not a probe
/// order (GH #5055).
pub(crate) fn ollama_tags_fallback_url(v1_base_url: &str) -> String {
format!("{}/api/tags", host_root_of(v1_base_url))
}

/// Resolve the context window LM Studio reports for `model_id` from a native
Expand Down Expand Up @@ -336,6 +353,37 @@ mod tests {
);
}

/// GH #5055: the `/api/tags` fallback URL is a sibling of `/v1` at the host
/// root. Appending to the `/v1` base would produce `/v1/api/tags` — the
/// exact malformed request LM Studio logs as `Unexpected endpoint or
/// method` (GH #5053).
#[test]
fn ollama_tags_fallback_url_is_host_rooted_not_v1_suffixed() {
assert_eq!(
ollama_tags_fallback_url("http://localhost:1234/v1"),
"http://localhost:1234/api/tags"
);
assert_eq!(
ollama_tags_fallback_url("http://127.0.0.1:1234/v1/"),
"http://127.0.0.1:1234/api/tags"
);
assert_eq!(
ollama_tags_fallback_url("https://lm.example.com/lmstudio/v1"),
"https://lm.example.com/lmstudio/api/tags"
);
// A host-rooted base (no /v1) is left alone.
assert_eq!(
ollama_tags_fallback_url("http://localhost:11434"),
"http://localhost:11434/api/tags"
);
for url in [
ollama_tags_fallback_url("http://localhost:1234/v1"),
ollama_tags_fallback_url("http://localhost:1234/v1/"),
] {
assert!(!url.contains("/v1/api/tags"), "malformed probe URL: {url}");
}
}

#[test]
fn context_window_prefers_loaded_then_max() {
let resp: LmStudioNativeModelsResponse = serde_json::from_str(
Expand Down
8 changes: 8 additions & 0 deletions src/openhuman/inference/local/ollama.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,14 @@ impl OllamaPullProgress {
pub(crate) struct OllamaTagsResponse {
#[serde(default)]
pub models: Vec<OllamaModelTag>,
/// Set when the server answered with an error envelope rather than a
/// catalog. LM Studio replies to unknown paths with `200 {"error": …}` and
/// no `models` (GH #5053), which is indistinguishable from a real catalog
/// by `models` alone — a fresh Ollama with nothing pulled legitimately
/// returns `{"models":[]}`. Callers must branch on this field, not on
/// emptiness.
#[serde(default)]
pub error: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
Expand Down
145 changes: 140 additions & 5 deletions src/openhuman/inference/local/service/lm_studio.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::lm_studio::{
apply_lm_studio_auth, lm_studio_base_url, LmStudioChatCompletionRequest,
LmStudioChatCompletionResponse, LmStudioChatMessage, LmStudioModelsResponse,
apply_lm_studio_auth, lm_studio_base_url, ollama_tags_fallback_url,
LmStudioChatCompletionRequest, LmStudioChatCompletionResponse, LmStudioChatMessage,
LmStudioModelsResponse,
};
use crate::openhuman::inference::local::ollama::OllamaModelTag;
use crate::openhuman::inference::local::ollama::{OllamaModelTag, OllamaTagsResponse};
use crate::openhuman::inference::model_ids;

use super::LocalAiService;
Expand Down Expand Up @@ -43,11 +44,15 @@ impl LocalAiService {
) -> Result<Vec<OllamaModelTag>, String> {
let base = lm_studio_base_url(config);
let url = format!("{base}/models");
// GH #5055: log the *resolved* discovery URL so a wrong base URL is
// diagnosable from app logs alone, without reproducing against the
// runtime's own request log.
tracing::debug!(
target: "local_ai::lm_studio",
%base,
%url,
"[local_ai:lm_studio] list_models: sending GET"
discovery_url = %url,
api = "openai_v1_models",
"[local_ai:lm_studio] list_models: resolved discovery URL — sending GET"
);

let request = self
Expand Down Expand Up @@ -78,6 +83,20 @@ impl LocalAiService {
body = %diagnostic_body_snippet(&body),
"[local_ai:lm_studio] list_models: non-success response"
);

// GH #5055: a 404 on `/v1/models` means this host is reachable but
// does not serve the OpenAI catalog. Try the Ollama-native
// `/api/tags` exactly once before giving up, so a runtime that only
// speaks the Ollama listing still discovers its models. Discovery is
// still selected by provider *type* first (`model_discovery_api`);
// this is a recovery path, never a probe order, and it never runs
// for any status other than 404.
if status == reqwest::StatusCode::NOT_FOUND {
if let Some(models) = self.list_ollama_tags_fallback(config, &base).await {
return Ok(models);
Comment thread
M3gA-Mind marked this conversation as resolved.
}
}

return Err(format!(
"lm studio models failed with status {}{}",
status,
Expand Down Expand Up @@ -120,6 +139,122 @@ impl LocalAiService {
.collect())
}

/// One-shot Ollama-native `/api/tags` fallback for a `/v1/models` 404.
///
/// Returns `Some(models)` only when the fallback actually produced a
/// catalog; every failure returns `None` so the caller surfaces the original
/// `/v1/models` error rather than a confusing second one. Logs at WARN on
/// entry because taking this path means the configured base URL and the
/// provider type disagree — the user should fix the configuration even
/// though discovery recovered (GH #5055).
async fn list_ollama_tags_fallback(
&self,
config: &Config,
base: &str,
) -> Option<Vec<OllamaModelTag>> {
let fallback_url = ollama_tags_fallback_url(base);
tracing::warn!(
target: "local_ai::lm_studio",
%base,
discovery_url = %fallback_url,
api = "ollama_api_tags",
"[local_ai:lm_studio] list_models: /v1/models returned 404 — retrying once against \
the Ollama-native /api/tags. Check the configured base URL: an OpenAI-compatible \
runtime should serve /v1/models."
);

let request = self
.http
.get(&fallback_url)
.timeout(std::time::Duration::from_secs(5));
let response = match apply_lm_studio_auth(request, config).send().await {
Ok(r) => r,
Err(e) => {
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
error = %e,
"[local_ai:lm_studio] /api/tags fallback request failed"
);
return None;
}
};

let status = response.status();
if !status.is_success() {
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
%status,
"[local_ai:lm_studio] /api/tags fallback returned non-success"
);
return None;
}

let body = match response.text().await {
Ok(b) => b,
Err(e) => {
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
error = %e,
"[local_ai:lm_studio] /api/tags fallback body read failed"
);
return None;
}
};
let payload: OllamaTagsResponse = match serde_json::from_str(&body) {
Ok(p) => p,
Err(e) => {
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
error = %e,
body = %diagnostic_body_snippet(&body),
"[local_ai:lm_studio] /api/tags fallback parse failed"
);
return None;
}
};

// Reject on an explicit error envelope, NOT on emptiness. LM Studio
// answers unknown paths with `200 {"error": …}` and no models
// (GH #5053) — that is the case that must fall through to the original
// /v1/models error. A fresh Ollama with nothing pulled yet legitimately
// returns `{"models":[]}`, and treating that as a failure hid a
// reachable runtime behind a 404, so the UI could not offer the
// model-download action.
if let Some(error) = payload
.error
.as_deref()
.map(str::trim)
.filter(|e| !e.is_empty())
{
tracing::debug!(
target: "local_ai::lm_studio",
url = %fallback_url,
error = %error,
"[local_ai:lm_studio] /api/tags fallback returned an error envelope — not a recovery"
);
return None;
}
if payload.models.is_empty() {
tracing::info!(
target: "local_ai::lm_studio",
url = %fallback_url,
"[local_ai:lm_studio] /api/tags fallback reached a reachable runtime with an empty catalog — recovering as zero models"
);
}

tracing::info!(
target: "local_ai::lm_studio",
url = %fallback_url,
model_count = payload.models.len(),
"[local_ai:lm_studio] recovered model discovery via the Ollama /api/tags fallback"
);
Some(payload.models)
}

pub(in crate::openhuman::inference::local::service) async fn has_lm_studio_model(
&self,
config: &Config,
Expand Down
Loading
Loading