Files
odysseus/tests/test_lmstudio_models_url.py
Muhammed Midlaj 4b0a977988 fix(models): probe /v1/models for path-less LM Studio endpoints
Probe /v1/models for path-less OpenAI-compatible model endpoints and surface clearer LM Studio diagnostics with the actual probed URL.
2026-06-15 15:09:50 +09:00

161 lines
6.6 KiB
Python

"""Regression coverage for LM Studio /v1 model-list endpoints (issue #25).
LM Studio's OpenAI-compatible surface exposes its model list at
``/v1/models`` (just like llama-server, vLLM, text-generation-webui). Two
distinct failure modes were reported by users:
1. Pasting ``http://localhost:1234`` (no ``/v1``) — ``build_models_url``
used to return ``http://localhost:1234/models``, which LM Studio does
not expose, so the user got a generic "No models found" error even
though the server was running and reachable.
2. Pasting ``http://localhost:1234/v1`` (with ``/v1``) — the model list
fetch was correct, but the error message gave the user no way to tell
whether the URL was wrong, the server was down, or the server was
reachable but had no model loaded.
This module pins both behaviors so future refactors don't regress them.
"""
import httpx
from src import endpoint_resolver, llm_core
def _neutralize_provider_detection(monkeypatch):
"""``_is_ollama_native_url`` matches any localhost host with an empty
path, which would route ``http://localhost:1234`` (LM Studio) into the
Ollama branch and probe ``/api/tags`` instead of ``/v1/models``. Force
provider detection to "openai" so the URL builder takes the LM Studio
path the user actually intends."""
monkeypatch.setattr(llm_core, "_is_ollama_native_url", lambda url: False)
# ── build_models_url: handle LM Studio base shapes ────────────────────
def test_build_models_url_inserts_v1_for_bare_host_port(monkeypatch):
"""`http://localhost:1234` must probe `/v1/models` for LM Studio."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
_neutralize_provider_detection(monkeypatch)
assert (
endpoint_resolver.build_models_url("http://localhost:1234")
== "http://localhost:1234/v1/models"
)
def test_build_models_url_accepts_v1_base(monkeypatch):
"""`http://localhost:1234/v1` must probe `/v1/models` (no double v1)."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
_neutralize_provider_detection(monkeypatch)
assert (
endpoint_resolver.build_models_url("http://localhost:1234/v1")
== "http://localhost:1234/v1/models"
)
def test_build_models_url_idempotent_for_explicit_models(monkeypatch):
"""`/v1/models` must probe `/v1/models` (normalize_base strips it)."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
_neutralize_provider_detection(monkeypatch)
assert (
endpoint_resolver.build_models_url("http://localhost:1234/v1/models")
== "http://localhost:1234/v1/models"
)
def test_build_models_url_strips_chat_completions(monkeypatch):
"""`/v1/chat/completions` must collapse to `/v1/models` (parity with #3330)."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
_neutralize_provider_detection(monkeypatch)
assert (
endpoint_resolver.build_models_url("http://localhost:1234/v1/chat/completions")
== "http://localhost:1234/v1/models"
)
def test_build_models_url_preserves_explicit_non_v1_path(monkeypatch):
"""User-supplied non-empty paths (e.g. `/openai`) must not be overridden
with `/v1`. We only insert `/v1` when the path is empty — that matches
the documented contract: a custom path is the caller's intent."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
_neutralize_provider_detection(monkeypatch)
assert (
endpoint_resolver.build_models_url("http://proxy.example.com/openai")
== "http://proxy.example.com/openai/models"
)
# ── list_model_ids: parse LM Studio's response ─────────────────────────
def test_llm_core_list_model_ids_queries_v1_models_for_lmstudio(monkeypatch):
"""Issue #25: probing `http://localhost:1234/v1` must hit `/v1/models`."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
monkeypatch.setattr(llm_core, "_configured_cached_model_ids", lambda url, **kwargs: [])
seen = []
def fake_get(url, headers=None, timeout=None):
seen.append(url)
request = httpx.Request("GET", url)
return httpx.Response(
200,
json={
"object": "list",
"data": [
{"id": "lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF"},
{"id": "qwen2.5-7b-instruct"},
],
},
request=request,
)
monkeypatch.setattr(llm_core.httpx, "get", fake_get)
assert llm_core.list_model_ids("http://localhost:1234/v1", timeout=1) == [
"lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF",
"qwen2.5-7b-instruct",
]
assert seen == ["http://localhost:1234/v1/models"]
def test_llm_core_list_model_ids_queries_v1_models_for_bare_lmstudio(monkeypatch):
"""Issue #25: probing `http://localhost:1234` (no /v1) must hit `/v1/models`."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
monkeypatch.setattr(llm_core, "_configured_cached_model_ids", lambda url, **kwargs: [])
# Localhost with empty path would otherwise be misclassified as Ollama
# (llm_core._is_ollama_native_url); neutralise that for the test.
monkeypatch.setattr(llm_core, "_is_ollama_native_url", lambda url: False)
seen = []
def fake_get(url, headers=None, timeout=None):
seen.append(url)
request = httpx.Request("GET", url)
return httpx.Response(200, json={"data": [{"id": "model-a"}]}, request=request)
monkeypatch.setattr(llm_core.httpx, "get", fake_get)
assert llm_core.list_model_ids("http://localhost:1234", timeout=1) == ["model-a"]
assert seen == ["http://localhost:1234/v1/models"]
def test_llm_core_list_model_ids_handles_empty_lmstudio_list(monkeypatch):
"""LM Studio returns `{"object":"list","data":[]}` when no model is loaded.
The helper must return `[]` cleanly so the caller can surface a clear
error (issue #25: previously the empty case was indistinguishable from
a connection failure)."""
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url)
monkeypatch.setattr(llm_core, "_configured_cached_model_ids", lambda url, **kwargs: [])
def fake_get(url, headers=None, timeout=None):
request = httpx.Request("GET", url)
return httpx.Response(200, json={"object": "list", "data": []}, request=request)
monkeypatch.setattr(llm_core.httpx, "get", fake_get)
assert llm_core.list_model_ids("http://localhost:1234/v1", timeout=1) == []