diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index a5216210a79..74eb8df8661 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -358,7 +358,10 @@ export function ChatView({ throw new Error('Hermes gateway unavailable') } - return gateway.request('model.options', { session_id: activeSessionId }) + return gateway.request('model.options', { + session_id: activeSessionId, + explicit_only: true + }) }, enabled: gatewayOpen }) diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 405fb49cd72..135e9e268f2 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -13,8 +13,10 @@ beforeAll(() => { const getGlobalModelInfo = vi.fn() const getGlobalModelOptions = vi.fn() const getAuxiliaryModels = vi.fn() +const getMoaModels = vi.fn() const setModelAssignment = vi.fn() const getRecommendedDefaultModel = vi.fn() +const saveMoaModels = vi.fn() const setEnvVar = vi.fn() const getHermesConfigRecord = vi.fn() const saveHermesConfig = vi.fn() @@ -24,8 +26,10 @@ vi.mock('@/hermes', () => ({ getGlobalModelInfo: () => getGlobalModelInfo(), getGlobalModelOptions: () => getGlobalModelOptions(), getAuxiliaryModels: () => getAuxiliaryModels(), + getMoaModels: () => getMoaModels(), setModelAssignment: (body: unknown) => setModelAssignment(body), getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug), + saveMoaModels: (body: unknown) => saveMoaModels(body), setEnvVar: (key: string, value: string) => setEnvVar(key, value), getHermesConfigRecord: () => getHermesConfigRecord(), saveHermesConfig: (config: unknown) => saveHermesConfig(config) @@ -45,15 +49,6 @@ beforeEach(() => { models: ['hermes-4', 'hermes-4-mini'], authenticated: true, capabilities: { 'hermes-4': { reasoning: true, fast: true } } - }, - // An unconfigured api_key provider — surfaced by the full-universe payload. - { - name: 'DeepSeek', - slug: 'deepseek', - models: [], - authenticated: false, - auth_type: 'api_key', - key_env: 'DEEPSEEK_API_KEY' } ] }) @@ -61,8 +56,9 @@ beforeEach(() => { main: { provider: 'nous', model: 'hermes-4' }, tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }] }) + getMoaModels.mockResolvedValue(null) setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] }) - getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null }) + getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null }) setEnvVar.mockResolvedValue({ ok: true }) getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } }) saveHermesConfig.mockResolvedValue({ ok: true }) @@ -80,43 +76,19 @@ async function renderModelSettings() { } describe('ModelSettings', () => { - it('loads the current main model and lists the full provider universe', async () => { + it('loads the current main model and lists configured providers only', async () => { await renderModelSettings() await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled()) await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled()) - // Open the provider Select — every provider from the full payload should be - // listed, including the unconfigured one with its "set up" hint. + // Open the provider Select — only configured providers should be listed. const triggers = await screen.findAllByRole('combobox') fireEvent.click(triggers[0]) - // "Nous" shows in both the trigger and the open list; the unconfigured - // provider + its setup hint are the unique signal of the full universe. + // "Nous" shows in both the trigger and the open list. expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0) - expect(await screen.findByText(/DeepSeek/)).toBeTruthy() - expect(await screen.findByText(/set up/)).toBeTruthy() - }) - - it('activates an unconfigured api_key provider inline by saving its key', async () => { - await renderModelSettings() - - await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled()) - - // Open the provider Select and pick the unconfigured provider. - const triggers = screen.getAllByRole('combobox') - fireEvent.click(triggers[0]) - const deepseekOption = await screen.findByText(/DeepSeek/) - fireEvent.click(deepseekOption) - - // The inline key input appears for an api_key provider that needs setup. - const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/) - fireEvent.change(keyInput, { target: { value: 'sk-test-123' } }) - - const activate = await screen.findByRole('button', { name: /Activate/ }) - fireEvent.click(activate) - - await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123')) + expect(screen.queryByText(/DeepSeek/)).toBeNull() }) it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => { diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 8f1aad94767..09c11b838bd 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -45,7 +45,10 @@ export function ModelVisibilityDialog({ queryKey: ['model-options', sessionId || 'global'], queryFn: (): Promise => { if (gw && sessionId) { - return gw.request('model.options', { session_id: sessionId }) + return gw.request('model.options', { + session_id: sessionId, + explicit_only: true + }) } return getGlobalModelOptions() diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx index 6e9fb1ef51f..780576670f4 100644 --- a/apps/desktop/src/components/onboarding/flow.tsx +++ b/apps/desktop/src/components/onboarding/flow.tsx @@ -237,7 +237,7 @@ function ConfirmingModelPanel({ // shows the same $/Mtok + Free/Pro info the picker and CLI do. const options = useQuery({ queryKey: ['onboarding-model-options', flow.providerSlug], - queryFn: () => getGlobalModelOptions() + queryFn: () => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false }) }) const providerRow = options.data?.providers?.find( diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index d6d08cb9711..0b81db3775b 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -99,7 +99,7 @@ function useApiKeyCatalog(): ApiKeyOption[] { // Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in // tests) is funneled into the same .catch instead of escaping. void Promise.resolve() - .then(() => getGlobalModelOptions()) + .then(() => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })) .then(res => { if (!cancelled) { setRows(res.providers ?? []) diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index e9ad04a5511..882b5588663 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -100,7 +100,7 @@ describe('Hermes REST session helpers', () => { [getHermesConfig, '/api/config'], [getHermesConfigDefaults, '/api/config/defaults'], [getGlobalModelInfo, '/api/model/info'], - [() => getGlobalModelOptions(), '/api/model/options'], + [() => getGlobalModelOptions(), '/api/model/options?explicit_only=1'], [getCronJobs, '/api/cron/jobs'] ] @@ -134,4 +134,24 @@ describe('Hermes REST session helpers', () => { profile: 'xiaoxuxu' }) }) + + it('defaults model options to configured providers only', async () => { + await getGlobalModelOptions() + + expect(api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/model/options?explicit_only=1' + }) + ) + }) + + it('can opt into unconfigured providers for onboarding flows', async () => { + await getGlobalModelOptions({ includeUnconfigured: true, refresh: true, explicitOnly: false }) + + expect(api).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/api/model/options?refresh=1&include_unconfigured=1' + }) + ) + }) }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index cf03e0d8365..3bfd02e276f 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -869,10 +869,28 @@ export function getUsageAnalytics(days = 30): Promise { }) } -export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise { +export function getGlobalModelOptions(opts?: { + refresh?: boolean + includeUnconfigured?: boolean + explicitOnly?: boolean +}): Promise { + const params = new URLSearchParams() + + if (opts?.refresh) { + params.set('refresh', '1') + } + + if (opts?.includeUnconfigured) { + params.set('include_unconfigured', '1') + } + + if (opts?.explicitOnly !== false) { + params.set('explicit_only', '1') + } + return window.hermesDesktop.api({ ...profileScoped(), - path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options', + path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index a76555ec667..11ae63bf202 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -1,12 +1,17 @@ import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes' interface ModelOptionsRequest { + /** When false, include ambient/unconfigured providers (onboarding/setup + * surfaces). Chat pickers default to true so only explicitly configured + * providers are listed (#56974). */ + explicitOnly?: boolean gateway?: HermesGateway refresh?: boolean sessionId?: null | string } export function requestModelOptions({ + explicitOnly = true, gateway, refresh = false, sessionId @@ -22,8 +27,12 @@ export function requestModelOptions({ params.refresh = true } + if (explicitOnly) { + params.explicit_only = true + } + return gateway.request('model.options', params) } - return getGlobalModelOptions(refresh ? { refresh: true } : undefined) + return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) }) } diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index c9c9606f349..f257af1e5fe 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -239,7 +239,7 @@ async function fetchProviderDefaultModel( let options try { - options = await getGlobalModelOptions() + options = await getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false }) } catch { return null } diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 8ac31020def..179d8697e9d 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1495,6 +1495,33 @@ def is_provider_explicitly_configured(provider_id: str) -> bool: if has_usable_secret(os.getenv(env_var, "")): return True + # 4. Check persisted credential-pool entries that came from EXPLICIT flows + # the user initiated inside Hermes (manual add / device-code / PKCE), plus + # env-backed pool entries. This intentionally excludes ambient borrowed + # sources like gh_cli / claude_code / qwen-cli. + try: + for entry in read_credential_pool(normalized): + if not isinstance(entry, dict): + continue + source = str(entry.get("source") or "").strip().lower() + if not source: + continue + if source.startswith("env:"): + # A stale env-seeded pool entry survives in auth.json after + # the user deletes the env var (#55790) — only count it when + # the referenced var still resolves to a usable secret NOW. + env_var = entry.get("source", "").split(":", 1)[1].strip() + if env_var and has_usable_secret(os.getenv(env_var, "")): + return True + continue + if ( + source in {"device_code", "loopback_pkce", "hermes_pkce", "manual"} + or source.startswith("manual:") + ): + return True + except Exception: + pass + return False diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index ad279ca4f72..08cfe322b87 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -111,6 +111,7 @@ def load_picker_context() -> ConfigContext: def build_models_payload( ctx: ConfigContext, *, + explicit_only: bool = False, include_unconfigured: bool = False, picker_hints: bool = False, canonical_order: bool = False, @@ -126,6 +127,10 @@ def build_models_payload( needs from a single substrate call. Flags: + - ``explicit_only``: keep only providers the user explicitly configured + (current provider, providers from config, or providers backed by + provider-specific env vars). This hides ambient / auto-seeded + credentials from desktop chat pickers. - ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that ``list_authenticated_providers`` didn't emit (TUI uses this to show the full provider universe in the picker). @@ -180,6 +185,9 @@ def build_models_payload( if moa_row is not None: rows = [moa_row] + [r for r in rows if str(r.get("slug", "")).lower() != "moa"] + if explicit_only: + rows = _filter_explicit_provider_rows(rows, ctx) + # --- Deduplicate: remove models from aggregators that overlap with # user-defined providers. When a local proxy (e.g. litellm-proxy) # serves a model whose name also appears in an aggregator's curated @@ -308,6 +316,37 @@ def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict return extras +def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: + """Keep only rows backed by explicit user configuration. + + ``list_authenticated_providers`` intentionally discovers ambient / auto- + seeded credentials (for example GitHub CLI -> Copilot). Desktop chat model + pickers want the narrower subset the user explicitly configured for Hermes. + """ + from hermes_cli.auth import is_provider_explicitly_configured + + current_slug = str(ctx.current_provider or "").strip().lower() + kept: list[dict] = [] + for row in rows: + slug = str(row.get("slug", "")).strip().lower() + if not slug: + continue + if row.get("is_user_defined"): + kept.append(row) + continue + if current_slug and slug == current_slug: + kept.append(row) + continue + if slug == "moa": + # MoA is a virtual routing mode, not an independently configured + # provider. Hide it from explicit-only pickers unless it is the + # current provider (handled above). + continue + if is_provider_explicitly_configured(slug): + kept.append(row) + return kept + + def _apply_picker_hints(rows: list[dict]) -> None: """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d7ff480b390..d8b9d4ac7af 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4284,7 +4284,12 @@ _AUX_TASK_SLOTS: Tuple[str, ...] = ( @app.get("/api/model/options") -def get_model_options(profile: Optional[str] = None, refresh: bool = False): +def get_model_options( + profile: Optional[str] = None, + refresh: bool = False, + include_unconfigured: bool = False, + explicit_only: bool = False, +): """Return authenticated providers + their curated model lists. REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the @@ -4303,18 +4308,15 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False): try: from hermes_cli.inventory import build_models_payload, load_picker_context - # include_unconfigured + picker_hints + canonical_order mirror the - # tui_gateway `model.options` JSON-RPC handler exactly, so every GUI - # surface fed by this endpoint (Settings → Model, the first-run - # onboarding picker) sees the SAME full provider universe `hermes model` - # exposes — not just the authenticated subset. Unconfigured providers - # come back as skeleton rows carrying `authenticated=False` + - # `auth_type`/`key_env`/`warning` so the GUI can render a setup - # affordance instead of hiding the provider entirely. + # Most desktop surfaces should only list providers the user has already + # configured. Onboarding opts into the full provider universe via + # include_unconfigured=1 so it can still render setup affordances for + # providers that are not yet authenticated. with _profile_scope(profile): return build_models_payload( load_picker_context(), - include_unconfigured=True, + explicit_only=bool(explicit_only), + include_unconfigured=bool(include_unconfigured), picker_hints=True, canonical_order=True, pricing=True, diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 09a839f1c2e..34c61aa1307 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -378,6 +378,44 @@ def test_include_unconfigured_skips_already_present_slugs(): assert or_rows[0]["models"] == ["m1"] # the authenticated row, not skeleton +def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_rows(): + rows = [ + {"slug": "openai-codex", "name": "OpenAI Codex", "models": ["gpt-5.4"], + "total_models": 1, "is_current": True, "is_user_defined": False, + "source": "hermes"}, + {"slug": "gemini", "name": "Gemini", "models": ["gemini-2.5-pro"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "built-in"}, + {"slug": "copilot", "name": "Copilot", "models": ["gpt-5.4"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "hermes"}, + {"slug": "nous", "name": "Nous", "models": ["anthropic/claude-sonnet-5"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "hermes"}, + {"slug": "custom:lab", "name": "Lab", "models": ["lab-1"], + "total_models": 1, "is_current": False, "is_user_defined": True, + "source": "user-config"}, + {"slug": "moa", "name": "MoA", "models": ["default"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "virtual"}, + ] + ctx = _empty_ctx(provider="openai-codex", model="gpt-5.4") + with ( + _list_auth_returning(rows), + patch( + "hermes_cli.auth.is_provider_explicitly_configured", + side_effect=lambda slug: slug == "gemini", + ), + ): + payload = build_models_payload(ctx, explicit_only=True) + + assert [row["slug"] for row in payload["providers"]] == [ + "openai-codex", + "gemini", + "custom:lab", + ] + + # ─── picker_hints ────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index a7fc2145995..7cc3ba8351d 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -316,6 +316,36 @@ class TestProfileScopedModel: resp = client.get("/api/model/options", params={"profile": "ghost"}) assert resp.status_code == 404 + def test_model_options_hides_unconfigured_providers_by_default(self, client, monkeypatch): + calls = [] + + monkeypatch.setattr( + "hermes_cli.inventory.load_picker_context", + lambda: object(), + ) + + def _fake_build_models_payload(_ctx, **kwargs): + calls.append(kwargs) + return {"providers": [], "model": "", "provider": ""} + + monkeypatch.setattr( + "hermes_cli.inventory.build_models_payload", + _fake_build_models_payload, + ) + + resp = client.get("/api/model/options") + assert resp.status_code == 200 + assert calls[-1]["explicit_only"] is False + assert calls[-1]["include_unconfigured"] is False + + resp = client.get("/api/model/options", params={"explicit_only": "1"}) + assert resp.status_code == 200 + assert calls[-1]["explicit_only"] is True + + resp = client.get("/api/model/options", params={"include_unconfigured": "1"}) + assert resp.status_code == 200 + assert calls[-1]["include_unconfigured"] is True + def test_model_info_unknown_profile_404(self, client, isolated_profiles): """Regression: the broad except used to convert the 404 into a 200 with empty model info ("no model set" — silently wrong).""" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 3286363dfd2..5e4dfea99d9 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5984,6 +5984,52 @@ def test_model_options_propagates_list_exception(monkeypatch): assert "catalog blew up" in resp["error"]["message"] +def test_model_options_hides_unconfigured_providers_by_default(monkeypatch): + from hermes_cli.inventory import ConfigContext + + calls = [] + + monkeypatch.setattr(server, "_resolve_model", lambda: "") + monkeypatch.setattr( + "hermes_cli.inventory.load_picker_context", + lambda: ConfigContext( + current_provider="", + current_model="", + current_base_url="", + user_providers={}, + custom_providers=[], + ), + ) + + def _fake_build_models_payload(_ctx, **kwargs): + calls.append(kwargs) + return {"providers": [], "model": "", "provider": ""} + + monkeypatch.setattr( + "hermes_cli.inventory.build_models_payload", + _fake_build_models_payload, + ) + + resp = server._methods["model.options"](99, {"session_id": ""}) + assert "result" in resp, resp + assert calls[-1]["explicit_only"] is False + assert calls[-1]["include_unconfigured"] is False + + resp = server._methods["model.options"]( + 100, + {"session_id": "", "explicit_only": True}, + ) + assert "result" in resp, resp + assert calls[-1]["explicit_only"] is True + + resp = server._methods["model.options"]( + 101, + {"session_id": "", "include_unconfigured": True}, + ) + assert "result" in resp, resp + assert calls[-1]["include_unconfigured"] is True + + # --------------------------------------------------------------------------- # prompt.submit — auto-title # --------------------------------------------------------------------------- diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8845128f9a5..3c0e8372d68 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12398,17 +12398,18 @@ def _(rid, params: dict) -> dict: ), current_base_url=getattr(agent, "base_url", "") if agent else "", ) - # picker_hints + canonical_order produce the TUI's required shape: + # picker_hints + canonical_order produce the TUI/desktop picker shape: # `authenticated`/`auth_type`/`key_env`/`warning` per row, in - # CANONICAL_PROVIDERS declaration order. include_unconfigured=True - # so the picker can show the full provider universe (with the - # setup-hint warning attached) instead of only authed rows. + # CANONICAL_PROVIDERS declaration order. Desktop pickers default to the + # configured subset; callers that need setup affordances can pass + # include_unconfigured=true explicitly. # Curated model lists are preserved — list_authenticated_providers # populates `models` from the curated catalog, not provider_model_ids # (which would pull non-agentic models like TTS/embeddings/etc.). payload = build_models_payload( ctx, - include_unconfigured=True, + explicit_only=bool(params.get("explicit_only")), + include_unconfigured=bool(params.get("include_unconfigured")), picker_hints=True, canonical_order=True, pricing=True, diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 8752dd371a2..149047e4002 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -60,7 +60,12 @@ export function ModelPicker({ useEffect(() => { gw.request('model.options', { ...(sessionId ? { session_id: sessionId } : {}), - ...(initialRefresh ? { refresh: true } : {}) + ...(initialRefresh ? { refresh: true } : {}), + // The TUI picker shows the full provider universe with setup + // affordances ("paste KEY to activate"), so opt into unconfigured + // rows — the backend now defaults to the configured subset for + // desktop chat pickers (#56974). + include_unconfigured: true }) .then(raw => { const r = asRpcResult(raw) diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index 554288e60ec..b05e389bdb4 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -138,6 +138,10 @@ export function ModelPickerDialog(props: Props) { { ...(sessionId ? { session_id: sessionId } : {}), ...(refresh ? { refresh: true } : {}), + // Dashboard picker mirrors the TUI: full provider universe with + // setup warnings. The backend now defaults to the configured + // subset (#56974), so opt into unconfigured rows explicitly. + include_unconfigured: true, }, ); diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index eff359518c1..4da9234ac5d 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -22,7 +22,7 @@ describe("api.getModelOptions", () => { await api.getModelOptions({ refresh: true }); expect(fetchMock).toHaveBeenCalledWith( - "/api/model/options?refresh=1", + "/api/model/options?refresh=1&include_unconfigured=1", expect.objectContaining({ credentials: "include" }), ); }); @@ -41,7 +41,7 @@ describe("api.getModelOptions", () => { await api.getModelOptions({ profile: "default", refresh: true }); expect(fetchMock).toHaveBeenCalledWith( - "/api/model/options?profile=default&refresh=1", + "/api/model/options?profile=default&refresh=1&include_unconfigured=1", expect.objectContaining({ credentials: "include" }), ); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1f14ee68b34..25128b306a4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -487,6 +487,11 @@ export const api = { const qs = new URLSearchParams(); if (profile) qs.set("profile", profile); if (refresh) qs.set("refresh", "1"); + // Dashboard surfaces (Models page, profile builder, cron) are + // management/setup UIs: keep the full provider universe with setup + // affordances. The endpoint now defaults to the configured subset for + // desktop chat pickers (#56974), so opt in explicitly here. + qs.set("include_unconfigured", "1"); const suffix = qs.toString() ? `?${qs.toString()}` : ""; return fetchJSON(`/api/model/options${suffix}`); },