From 8b9c177ad6a0e9394abad392cd67540eceb44e4c Mon Sep 17 00:00:00 2001 From: agentforce314 Date: Sat, 8 Aug 2026 09:31:04 -0700 Subject: [PATCH] fix(desktop): model picker shows only configured providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker listed all ~30 registry providers, most unconfigured (marked 'paste KEY to activate'). Now it shows only providers the user actually set up: a key-taking provider with credentials present (api_key in config, or a subscription/OAuth — the catalog folds both into 'authenticated'), plus whichever the live session runs on. Local no-key servers (ollama/vllm/sglang) are excluded unless active — the user didn't configure them. Also: the config-only catalog no longer truncates the active provider's model list to just default_model — it shows the provider's FULL registry list, so every model (e.g. deepseek-v4-flash alongside deepseek-v4-pro) is selectable in the picker. configured_providers_only() filters both catalog paths (config-only welcome screen + live-session list_model_providers) so the picker is consistent. Verified live: picker shows exactly the 6 configured providers (anthropic, deepseek, minimax, openai, openrouter, zai) with the ~24-provider long tail gone; deepseek exposes all 4 models. Tested the deepseek provider + deepseek-v4-flash end-to-end — session boots on it and a real turn returns '42' for 7x6, no errors. 3 new pytest cases; 61 desktop tests green. Co-Authored-By: Claude Fable 5 --- src/server/desktop_gateway_methods.py | 37 +++++++++++++++++++++++---- tests/server/test_desktop_sessions.py | 9 ++++++- tests/server/test_desktop_slash.py | 36 +++++++++++++++++++++----- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/server/desktop_gateway_methods.py b/src/server/desktop_gateway_methods.py index d75699d8..77331703 100644 --- a/src/server/desktop_gateway_methods.py +++ b/src/server/desktop_gateway_methods.py @@ -326,13 +326,34 @@ async def respond_approval(self, choice: str) -> dict[str, Any]: return {"resolved": True} +def configured_providers_only(providers: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Keep only providers the user has actually configured. + + The registry knows ~30 providers, but the desktop picker should show just + the ones the user set up: a key-taking provider with credentials present + (an ``api_key`` in config, or a subscription/OAuth — the catalog folds both + into ``authenticated``), plus whichever the live session is running on. + Everything else is disabled by not appearing. + + Local, no-key providers (Ollama / vLLM / SGLang, ``auth_type == "none"``) + report as ``authenticated`` because they need no key — but the user didn't + *configure* them, so they're excluded unless they're the active provider. + """ + return [ + p for p in providers + if p.get("is_current") + or (p.get("authenticated") and p.get("auth_type") == "api_key") + ] + + def _catalog_from_config() -> dict[str, Any]: - """Full model catalog from config alone — no live session required. + """Configured model catalog from config alone — no live session required. Mirrors the agent-server's ``list_model_providers`` control, but reads the default provider + its configured model list straight from config so the desktop model picker populates on the welcome screen (before any session - exists). Sync (config + registry access); call via ``to_thread``. + exists), and filters to only configured providers. Sync (config + registry + access); call via ``to_thread``. """ from src.providers.catalog import provider_catalog @@ -350,9 +371,12 @@ def _catalog_from_config() -> dict[str, Any]: provider = None try: + # current_models is left unset so the active provider shows its FULL + # registry model list (e.g. every deepseek model), not just the one + # `default_model` from config — the picker is where you switch models. providers = provider_catalog( current=provider, - current_models=models or None, + current_models=None, current_ready=bool(provider), ) except Exception: # noqa: BLE001 @@ -361,7 +385,7 @@ def _catalog_from_config() -> dict[str, Any]: return { "model": models[0] if models else None, "provider": provider, - "providers": providers, + "providers": configured_providers_only(providers), } @@ -578,10 +602,13 @@ async def model_options(self, params: dict[str, Any]) -> dict[str, Any]: if session is not None: result = await session.control_query("list_model_providers", {}) if isinstance(result, dict) and result.get("providers"): + # Only configured providers (+ the running one) — same rule as + # the config-only path, so the picker is consistent whether or + # not a session is live. return { "model": result.get("fusion") or result.get("model"), "provider": result.get("provider"), - "providers": result.get("providers"), + "providers": configured_providers_only(result["providers"]), } # No live session yet (the welcome screen opens the picker before the # first prompt), or the session couldn't answer — enumerate the whole diff --git a/tests/server/test_desktop_sessions.py b/tests/server/test_desktop_sessions.py index 4345df4b..b683d21f 100644 --- a/tests/server/test_desktop_sessions.py +++ b/tests/server/test_desktop_sessions.py @@ -150,8 +150,15 @@ def test_settings_panel_rest_routes( monkeypatch.setattr("src.config.get_provider_config", lambda n: {"default_model": "claude-sonnet-4-6"}) + # The catalog is filtered to configured providers only; the panel needs a + # valid, non-empty list (the current provider is always present), not a + # specific count. options = rest.get("/api/model/options", headers=AUTH).json() - assert "providers" in options and len(options["providers"]) > 5 + assert "providers" in options and isinstance(options["providers"], list) + assert all( + p.get("is_current") or (p.get("authenticated") and p.get("auth_type") == "api_key") + for p in options["providers"] + ) aux = rest.get("/api/model/auxiliary", headers=AUTH).json() assert aux["main"] == {"model": "claude-sonnet-4-6", "provider": "anthropic"} diff --git a/tests/server/test_desktop_slash.py b/tests/server/test_desktop_slash.py index 6babd595..6cf4a045 100644 --- a/tests/server/test_desktop_slash.py +++ b/tests/server/test_desktop_slash.py @@ -114,16 +114,38 @@ async def boom(subtype: str, params: dict) -> object: assert "backend exploded" in res["output"] -# ─── model catalog from config (no live session) ───────────────────────────── +# ─── model catalog: configured providers only ──────────────────────────────── -def test_catalog_from_config_populates_without_session() -> None: +def test_configured_providers_only_filters_the_registry() -> None: + from src.server.desktop_gateway_methods import configured_providers_only + + catalog = [ + {"slug": "deepseek", "authenticated": True, "auth_type": "api_key"}, + {"slug": "openai", "authenticated": True, "auth_type": "api_key"}, + # Configured key-provider that the user hasn't set up → dropped. + {"slug": "groq", "authenticated": False, "auth_type": "api_key"}, + # Local no-key server → dropped (not user-configured) unless active. + {"slug": "ollama", "authenticated": True, "auth_type": "none"}, + # The running provider is always kept, even if the probe missed its key. + {"slug": "vllm", "authenticated": False, "auth_type": "none", "is_current": True}, + ] + kept = {p["slug"] for p in configured_providers_only(catalog)} + assert kept == {"deepseek", "openai", "vllm"} + + +def test_catalog_from_config_shows_only_configured() -> None: from src.server.desktop_gateway_methods import _catalog_from_config result = _catalog_from_config() - # The registry always knows anthropic + deepseek + more, regardless of - # which providers are configured — the picker needs the full list. - slugs = {p["slug"] for p in result["providers"]} - assert "anthropic" in slugs - assert len(result["providers"]) > 5 + # Every returned provider is either configured (key + authenticated) or the + # current one — never the unconfigured long tail. + for p in result["providers"]: + assert p.get("is_current") or ( + p.get("authenticated") and p.get("auth_type") == "api_key" + ), p["slug"] + # The active provider shows its FULL model list, not just default_model. + current = next((p for p in result["providers"] if p.get("is_current")), None) + if current is not None: + assert len(current["models"]) >= 1