From 40aa93443164507829428bfbae3235d4d7a063f8 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Tue, 19 May 2026 14:54:06 +0200 Subject: [PATCH] fix(setup): filter LM Studio models by state == "loaded_instance" LM Studio's /v1/models returns all downloaded models, not just loaded ones. Use /api/v0/models with state filtering in both fetch_loaded_models() and _fetch_local_models() so only RAM-resident models are shown as loaded. This also restores the _choose_model() fallback that offers downloaded-but- unloaded models when nothing is active in LM Studio. Co-Authored-By: Claude Sonnet 4.6 --- src/pyra/setup/wizard.py | 18 ++++++++++-- tests/unit/test_setup_wizard.py | 52 +++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/pyra/setup/wizard.py b/src/pyra/setup/wizard.py index da66a0b..d3897a6 100644 --- a/src/pyra/setup/wizard.py +++ b/src/pyra/setup/wizard.py @@ -386,7 +386,14 @@ def fetch_loaded_models(provider: Provider) -> list[str]: resp = httpx.get(f"{provider.base_url}/api/ps", timeout=3.0) resp.raise_for_status() return [m["name"] for m in resp.json().get("models", [])] - else: + elif provider.id == "lmstudio": + resp = httpx.get("http://localhost:1234/api/v0/models", timeout=3.0) + resp.raise_for_status() + return [ + m["id"] for m in resp.json().get("data", []) + if m.get("state") == "loaded_instance" + ] + else: # llamacpp — /models returns only the active loaded model resp = httpx.get(f"{provider.base_url}/models", timeout=3.0) resp.raise_for_status() return [m["id"] for m in resp.json().get("data", [])] @@ -407,7 +414,7 @@ def _show_local_model_status(provider: Provider) -> None: def _fetch_local_models(provider: Provider) -> list[str]: - """Return currently loaded/available models from a local provider's API.""" + """Return currently loaded models from a local provider's API.""" if not provider.base_url: return [] try: @@ -415,6 +422,13 @@ def _fetch_local_models(provider: Provider) -> list[str]: resp = httpx.get(f"{provider.base_url}/api/tags", timeout=3.0) resp.raise_for_status() return [m["name"] for m in resp.json().get("models", [])] + elif provider.id == "lmstudio": + resp = httpx.get("http://localhost:1234/api/v0/models", timeout=3.0) + resp.raise_for_status() + return [ + m["id"] for m in resp.json().get("data", []) + if m.get("state") == "loaded_instance" + ] else: resp = httpx.get(f"{provider.base_url}/models", timeout=3.0) resp.raise_for_status() diff --git a/tests/unit/test_setup_wizard.py b/tests/unit/test_setup_wizard.py index 89fc25f..5cf77f7 100644 --- a/tests/unit/test_setup_wizard.py +++ b/tests/unit/test_setup_wizard.py @@ -96,14 +96,34 @@ def test_suggest_plugins_multiple_categories(monkeypatch): # ── _fetch_local_models ──────────────────────────────────────────────────────── -def test_fetch_local_models_lmstudio_returns_model_ids(monkeypatch): +def test_fetch_local_models_lmstudio_returns_loaded_model_ids(monkeypatch): import pyra.setup.wizard as wiz mock_resp = MagicMock() - mock_resp.json.return_value = {"data": [{"id": "gemma-4b"}, {"id": "llama3"}]} + mock_resp.json.return_value = { + "data": [ + {"id": "gemma-4b", "state": "loaded_instance"}, + {"id": "llama3", "state": "not_loaded"}, + ] + } mock_resp.raise_for_status = lambda: None monkeypatch.setattr(wiz.httpx, "get", lambda *a, **kw: mock_resp) from pyra.setup.providers import get_provider - assert wiz._fetch_local_models(get_provider("lmstudio")) == ["gemma-4b", "llama3"] + assert wiz._fetch_local_models(get_provider("lmstudio")) == ["gemma-4b"] + + +def test_fetch_local_models_lmstudio_filters_unloaded(monkeypatch): + import pyra.setup.wizard as wiz + mock_resp = MagicMock() + mock_resp.json.return_value = { + "data": [ + {"id": "model-a", "state": "not_loaded"}, + {"id": "model-b", "state": "not_loaded"}, + ] + } + mock_resp.raise_for_status = lambda: None + monkeypatch.setattr(wiz.httpx, "get", lambda *a, **kw: mock_resp) + from pyra.setup.providers import get_provider + assert wiz._fetch_local_models(get_provider("lmstudio")) == [] def test_fetch_local_models_ollama_returns_model_names(monkeypatch): @@ -392,17 +412,37 @@ def test_fetch_loaded_models_ollama_uses_api_ps(monkeypatch): assert any("/api/ps" in u for u in calls) -def test_fetch_loaded_models_lmstudio_uses_models_endpoint(monkeypatch): +def test_fetch_loaded_models_lmstudio_uses_beta_api_and_filters(monkeypatch): import pyra.setup.wizard as wiz from pyra.setup.providers import get_provider mock_resp = MagicMock() - mock_resp.json.return_value = {"data": [{"id": "gemma-4b"}]} + mock_resp.json.return_value = { + "data": [ + {"id": "gemma-4b", "state": "loaded_instance"}, + {"id": "llama3", "state": "not_loaded"}, + ] + } mock_resp.raise_for_status = lambda: None calls = [] monkeypatch.setattr(wiz.httpx, "get", lambda url, **kw: (calls.append(url), mock_resp)[1]) result = wiz.fetch_loaded_models(get_provider("lmstudio")) assert result == ["gemma-4b"] - assert any("/models" in u for u in calls) + assert any("/api/v0/models" in u for u in calls) + + +def test_fetch_loaded_models_lmstudio_filters_unloaded(monkeypatch): + import pyra.setup.wizard as wiz + from pyra.setup.providers import get_provider + mock_resp = MagicMock() + mock_resp.json.return_value = { + "data": [ + {"id": "model-a", "state": "not_loaded"}, + {"id": "model-b", "state": "not_loaded"}, + ] + } + mock_resp.raise_for_status = lambda: None + monkeypatch.setattr(wiz.httpx, "get", lambda url, **kw: mock_resp) + assert wiz.fetch_loaded_models(get_provider("lmstudio")) == [] def test_fetch_loaded_models_returns_empty_on_error(monkeypatch):