fix(inference): probe /v1/models for OpenAI-compatible local runtimes (Closes #5053)#5067
Conversation
…Closes tinyhumansai#5053) A custom OpenAI-compatible BYOK endpoint on localhost (e.g. LM Studio at http://localhost:1234/v1) was misrouted through the Ollama discovery path: local-AI diagnostics gated the /v1/models probe on provider == LmStudio, so any runtime whose provider tag defaulted to "ollama" (OMLX, local-openai, or a custom BYOK endpoint) was probed at <base>/api/tags -> GET /v1/api/tags. LM Studio logs that as "Unexpected endpoint or method" and returns an empty catalog, silently breaking model discovery. Select the discovery API by provider TYPE, not by "is it localhost": add model_discovery_api(provider, base_url) which returns /api/tags only for a genuine Ollama runtime and /v1/models for every OpenAI-compatible one — keyed on the OpenAI /v1 endpoint type so a localhost LM Studio server is no longer mistaken for Ollama. Route diagnostics through it. Adds unit coverage for the endpoint selection (ollama slug -> /api/tags; OpenAI-compatible localhost -> /v1/models) and a diagnostics-level regression test that serves only /v1/models and asserts discovery succeeds.
📝 WalkthroughWalkthroughChangesLocal model discovery routing
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1171031718
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…5053-openai-compatible-model-discovery
|
@coderabbitai review |
✅ Action performedReview finished.
|
sanil-23
left a comment
There was a problem hiding this comment.
The mechanism here is right and the root-cause analysis is genuinely excellent — deciding by endpoint type rather than "is it localhost" is the correct call, and it directly answers the fix suggested in #5053. My one blocking concern is scope.
The fix is discovery-only; inference still misroutes for the same config
diagnostics.rs:23 is one of 15 sites gated on provider_from_config(config) == LocalAiProvider::LmStudio. This PR corrects that gate at the diagnostics leaf, but the chat path is gated on the same expression and is unchanged.
Tracing the exact #5053 config (provider = "ollama", base_url = "http://localhost:1234/v1"):
Discovery — fixed by this PR:
provider_from_config() -> Ollama (line 23 gate not taken)
model_discovery_api() -> OpenAiModels <- NEW: /v1 endpoint wins
-> GET http://localhost:1234/v1/models OK
Chat (public_infer.rs:279) — untouched:
provider_from_config() -> Ollama (gate not taken)
-> falls through to the Ollama branch
base_url = ollama_base_url_from_config(config) -> "http://localhost:1234/v1"
-> POST {base_url}/api/chat
-> POST http://localhost:1234/v1/api/chat BROKEN
public_infer.rs:341 builds the URL with format!("{base_url}/api/chat"), and ollama_base_url_from_config preserves the path component (it only rewrites 0.0.0.0/[::] hosts and rejects ollama.com), so the /v1 survives. That is structurally the same malformed URL as the /v1/api/tags this PR fixes — same root cause, one function over.
Worth noting the premises are identical: this PR is only necessary because the saved slug is ollama (a lm_studio slug would already route correctly at line 23). So the exact configuration that motivates this PR is the configuration for which chat stays broken. The two can't be separated.
Resulting user experience
Per #5053, LM Studio returns 200 for unrecognized endpoints (Unexpected endpoint or method ... Returning 200 anyway). So POST /v1/api/chat won't 404 cleanly — it returns 200 with a body that isn't an OllamaChatResponse, landing at public_infer.rs:367:
.map_err(|e| format!("ollama chat response parse failed: {e}"))?Net effect: the model now appears in the picker (this PR working as intended), the user selects it, and chat fails with an error naming Ollama for a provider they configured as LM Studio. To be clear this is not a regression — nobody who had working chat loses it — but it moves the failure from setup time to use time and makes it harder to self-diagnose than the current empty-list symptom.
Blast radius
Remaining sites on the slug gate:
| Site | Behavior for this config |
|---|---|
public_infer.rs:279, :548 |
Chat + inference -> Ollama /api/chat |
assets.rs:61, 294-337, 522, 680 |
Readiness/warning surface computed as Ollama |
bootstrap.rs:180, 471 |
Bootstrap probe; lmstudio:// model URI |
model_ids.rs:82, 154 |
Effective chat-model id resolution |
Suggested direction
Rather than another inline check, apply the endpoint-type correction at the decision function so every call site inherits it:
/// Effective local provider - the saved slug corrected by endpoint type.
/// A `/v1` base is OpenAI-compatible regardless of a slug that defaulted
/// to `ollama` (GH #5053).
pub(crate) fn effective_provider_from_config(config: &Config) -> LocalAiProvider {
match model_discovery_api(&config.local_ai.provider, &lm_studio_base_url(config)) {
ModelDiscoveryApi::OpenAiModels => LocalAiProvider::LmStudio,
ModelDiscoveryApi::OllamaTags => LocalAiProvider::Ollama,
}
}...then migrate the provider_from_config(config) == LmStudio call sites. Patching leaves means 15 sites to find individually, and whichever one gets missed reproduces this exact bug.
If that's too large for this PR, that is a completely legitimate call — but then please retarget to Refs #5053 rather than Closes, since the issue's acceptance criteria include "loads and responds to chat and agent task requests through the tinyagents module", and open a follow-up for inference dispatch.
Secondary: diagnostics payload shape changes for rerouted configs
diagnostics.rs:476-496 — lm_studio_diagnostics returns a materially different report than the Ollama branch: ollama_running: false, no context_requirement, no chat_eligibility/embedding_eligibility, no per-model eligibility/chat_capable/size/modified_at, and "provider": "lm_studio" even for an OMLX/local-openai/custom BYOK runtime.
ModelStatusSection.tsx:492 renders the status pill directly off diagnostics.ollama_running, so a working endpoint on this path displays as not running. The memory-layer context gate (MIN_CONTEXT_TOKENS, and the TAURI-RUST-4P6 chat_capable filter) also silently stops applying.
There is a narrow real regression here: provider = "omlx" on a host-rooted base previously took the Ollama branch and got the full report; it now takes the slug arm into lm_studio_diagnostics and gets the reduced shape. Worth either calling out in the description or populating context_requirement + eligibility from the native /api/v0/models data that lm_studio_context_window_for already parses.
Smaller items
-
diagnostics.rs:27-46— the routing decision is evaluated againstollama_base_url_from_config(config-first precedence) but delegates tolm_studio_diagnostics, which recomputes vialm_studio_base_url(env-first:OPENHUMAN_LM_STUDIO_BASE_URL,LM_STUDIO_BASE_URL, then config). With either env var set you can decide "OpenAI-compatible" from one URL and probe a different host. Resolve the base once and pass it down. -
diagnostics.rs:41-44—base_urlis logged raw. Neighbouring logs go throughredact_ollama_base_url(ollama.rs:127) /redact_url_for_logprecisely because a BYOK base can carryuser:pass@; AGENTS.md requires never logging secrets. Prefertracing::debug!with structured fields to match the rest of the file too. -
provider.rs:75-86—endpoint_is_openai_v1is stricter thannormalize_lm_studio_base_url(lm_studio.rs:94-98), which deliberately strips/modelsand/chat/completionssuffixes because users paste them. A base saved ashttp://localhost:1234/v1/modelsfails detection, takes the Ollama branch, and producesGET /v1/models/api/tags— the same bug class. The test atprovider.rs:142locks this in. If the strictness is deliberate, worth saying so in the doc comment. -
ollama_admin_tests.rs:781— the new test depends onlm_studio_base_url, which readsOPENHUMAN_LM_STUDIO_BASE_URL/LM_STUDIO_BASE_URLbefore config, butinference_test_guard()(local/mod.rs:11-15) is only a mutex and clears nothing. A developer with either var exported gets a confusing failure. The Ollama side solved this withOllamaEnvGuard.
What's good
- Root-cause writeup names the exact collapsing behavior in
provider_from_configand explains why endpoint type beats the slug — unusually clear. - Selectors are pure and total; reusing
normalize_providerkeeps alias handling in one place. - The regression test's mock serves only
/v1/modelswith no/api/tags, so it genuinely fails on the old code rather than passing incidentally. - Error/timeout handling on the delegated path is sound: bounded 5s probe, no unwraps, no hang path, graceful degradation into
issues[].LmStudioModelsResponse.datais#[serde(default)], so a conforming-but-empty payload yields an empty list rather than an error. - Genuine Ollama behavior is untouched — the early return sits ahead of all Ollama work.
CI note
The 4 cancelled checks are not failures — they are duplicate PR Quality (soft) runs from the same trigger on 2c220df, cancelled by the concurrency group, with a sibling run (29755301486) green. Head commit is 13 SUCCESS / 6 SKIPPED / 0 FAILURE. No re-run needed.
Requesting changes on the scope question only. Happy to approve as-is under Refs #5053 with a follow-up issue for inference dispatch, if you'd prefer to land the discovery fix now.
Reviewed with Claude Code.
Summary
GET /api/tags, every OpenAI-compatible runtime usesGET /v1/models.http://localhost:1234/v1) that were being probed with the Ollama-only/api/tags, producingGET /v1/api/tagsand an empty model list.model_discovery_api(provider, base_url)+endpoint_is_openai_v1(base_url)selectors and routes local-AI diagnostics through them.Problem
When a custom OpenAI-compatible provider (LM Studio serving
nvidia/nemotron-3-nano-omni) is configured as a BYOK endpoint onhttp://localhost:1234/v1, the app sendsGET /v1/api/tags— an Ollama-specific listing endpoint — instead of the OpenAI-standardGET /v1/models. LM Studio logsUnexpected endpoint or method (GET /v1/api/tags), returns 200 with an incompatible payload, and model discovery silently fails so the model is unavailable in the tinyagents module.Root cause:
LocalAiService::diagnosticsgated the OpenAI-compatible/v1/modelsprobe onprovider_from_config(config) == LmStudio. Butprovider_from_configcollapses everything that isn't literallylm_studioontoOllama— including OMLX,local-openai, and any custom BYOK endpoint whose provider tag defaulted toollama. Those all fell through to the Ollama branch and were probed at<base>/api/tags. With an OpenAI/v1base that yieldsGET /v1/api/tags. (Theinference_list_modelsRPC path already probed/modelscorrectly; the break was in the diagnostics discovery path that populates the selectable-model list.)Solution
ModelDiscoveryApiselector insrc/openhuman/inference/local/provider.rs:endpoint_is_openai_v1(base_url)— true when the URL path is the OpenAI/v1root. A genuine Ollama base is host-rooted (http://localhost:11434) with no/v1, so this separates the two API shapes by endpoint type, not by "is it localhost" (the conflation that misidentified LM Studio as Ollama).model_discovery_api(provider, base_url)— returnsOllamaTagsonly for a genuineollamaruntime on a host-rooted base; returnsOpenAiModelsfor an explicitlm_studio/omlxslug or any OpenAI/v1endpoint. The/v1clause rescues a custom BYOK localhost endpoint whose provider tag still defaults toollama.diagnostics()now routes any OpenAI-compatible/v1local endpoint to the existing/v1/modelsdiagnostics path before it can reach the Ollama/api/tagsbranch. Genuine Ollama (host-rooted base) is unaffected.Design notes / tradeoffs: the fix keys on the OpenAI
/v1endpoint type in addition to the provider slug, because the savedprovidertag is an unreliable signal (it defaults toollama). This also incidentally fixes the same misrouting for OMLX/local-openairuntimes. Ollama's own OpenAI-compatible/v1/modelsworks too, so even a hand-edited Ollama base ending in/v1degrades gracefully.Submission Checklist
diff-cover) meet the gate enforced by.github/workflows/ci-lite.yml. Changed lines are the two selectors + the diagnostics reroute, all exercised by the new unit and regression tests.N/A: behaviour-only change(no feature row added/removed/renamed indocs/TEST-COVERAGE-MATRIX.md).## Related—N/A: no matrix feature ID applies to this internal routing fix.N/A: does not change a release-cut surface.Closes #NNNin the## RelatedsectionImpact
local-openai, custom BYOK). No mobile/web/CLI surface change./v1endpoints change from/api/tagsto/v1/models.Related
AI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
fix/GH-5053-openai-compatible-model-discovery117103171Validation Run
pnpm --filter openhuman-app format:check— N/A: noapp/src(frontend) files changed.pnpm typecheck— N/A: no TypeScript changed (Rust-only change).cargo test --lib -- model_discovery_api endpoint_is_openai_v1 diagnostics_openai_compatible_v1_endpoint→ 4 passed.cargo fmton touched files;GGML_NATIVE=OFF cargo check --libclean.app/src-taurifiles changed.Validation Blocked
command:N/Aerror:N/Aimpact:N/ABehavior Changes
local-openai/custom BYOK on a/v1base) are probed for models at/v1/modelsinstead of/api/tags.nvidia/nemotron-3-nano-omni) now appear as selectable and noUnexpected endpointerrors are logged.Parity Contract
provider = ollama) still uses/api/tags— unchanged.model_discovery_apireturnsOllamaTagsforollama/empty provider on a non-/v1base (covered bymodel_discovery_api_uses_tags_for_genuine_ollama).Duplicate / Superseded PR Handling
Summary by CodeRabbit
Bug Fixes
/v1endpoints.Tests