Model picker filtering, visibility, and Copilot auth fixes

NousResearch/hermes-agent · cluster 12655 · agent

Data as of

23 open items. Fix merged in #60514. Check it covers your case.

The short answer

13open issues
10open PRs
42items grouped
Known?
Yes. 42 related items grouped here, 11 issues already closed.
Fix event
A fix was merged to main in PR #60514. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #60514. That is separate from the fix event.
Still open
13 issues and 10 PRs.

These items cover two adjacent themes: controlling which models and providers appear in model pickers (allowlists, hiding unused providers, favorites/LRU, explicit-only filtering, persistence of preferences), and a recurring issue with the Copilot auth layer incorrectly falling back to gh auth token, causing false-positive availability signals and raising security concerns.

Open issues13

  • Issue#13796/model picker: add option to show only user-configured models
  • Issue#15267[Feature Request] Filter unconfigured providers from model selection UI
  • Issue#16608[Feature]: Model allowlist — let users control which providers appear in /model list
  • Issue#23117feat(tui/cli): allow users to bookmark/favorite models for quick switching
  • Issue#25246[Bug]: copilot_auth.py fallback to gh auth token causes false positive for Copilot availability
  • Issue#35818feat(model): picker_providers config key to filter /model picker list
  • Issue#42085feat(model_switch): respect picker_providers whitelist + custom provider improvements
  • Issue#46304Option to hide unconfigured providers from model switcher
  • Issue#49114Desktop model visibility preferences revert because they are stored only in localStorage
  • Issue#51652[Bug]: copilot credential auto-seeded from gh CLI with no consent gate — contaminates credential pool & breaks subagent/delegation routing for unrelated providers (model_not_supported)
  • Issue#53113[Feature]: Add model_catalog.visible_models config to filter the model picker
  • Issue#60379严重安全违规:Hermes 在用户不知情、未授权的情况下窃取 GitHub Token (gh auth token)
  • Issue#60382CRITICAL SECURITY VIOLATION: Hermes silently steals your GitHub token via [redacted:github-token] without consent

Open PRs10

  • PR#14571feat(/model): honor picker_providers across pickers
  • PR#18023feat(cli): add picker_configured_only flag + /model configured (#13796)
  • PR#25250fix(copilot): stop using gh auth as token fallback
  • PR#26818fix(copilot): remove gh auth token fallback
  • PR#27025feat(models): add model_allowlist filter for dynamic-discovery providers
  • PR#51655fix(credential-pool): gate copilot auto-seed behind explicit provider configuration (#51652)
  • PR#52094fix(desktop): persist model visibility preferences to profile-scoped backend store (#49114)
  • PR#52440feat(gateway): optional model.allowlist to restrict /model targets
  • PR#60404fix(security): prevent unauthorized GitHub token access from gh CLI
  • PR#131881fix(copilot): stop using gh auth as token fallback [risk 0.85]

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #60514 fix: limit desktop model pickers to explicit providers (#56974)

    merged to main

    files: apps/desktop/src/app/chat/index.tsx, apps/desktop/src/app/settings/model-settings.test.tsx, apps/desktop/src/components/model-visibility-dialog.tsx, apps/desktop/src/components/onboarding/flow.tsx, apps/desktop/src/components/onboarding/index.tsx, apps/desktop/src/hermes.test.ts, apps/desktop/src/hermes.ts, apps/desktop/src/lib/model-options.ts, apps/desktop/src/store/onboarding.ts, hermes_cli/auth.py, hermes_cli/inventory.py, hermes_cli/web_server.py, scripts/release.py, tests/hermes_cli/test_auth_provider_gate.py, tests/hermes_cli/test_inventory.py, tests/hermes_cli/test_web_server_profile_unification.py, tests/test_tui_gateway_server.py, tui_gateway/server.py, ui-tui/src/components/modelPicker.tsx, web/src/components/ModelPickerDialog.tsx (+2 more)

    Show the diff
    • apps/desktop/src/app/chat/index.tsx
      @@ -358,7 +358,10 @@ export function ChatView({
               throw new Error('Hermes gateway unavailable')
             }
       
      -      return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
      +      return gateway.request<ModelOptionsResponse>('model.options', {
      +        session_id: activeSessionId,
      +        explicit_only: true
      +      })
           },
           enabled: gatewayOpen
         })
    • 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 () => {
    • apps/desktop/src/components/model-visibility-dialog.tsx
      @@ -45,7 +45,10 @@ export function ModelVisibilityDialog({
           queryKey: ['model-options', sessionId || 'global'],
           queryFn: (): Promise<ModelOptionsResponse> => {
             if (gw && sessionId) {
      -        return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
      +        return gw.request<ModelOptionsResponse>('model.options', {
      +          session_id: sessionId,
      +          explicit_only: true
      +        })
             }
       
             return getGlobalModelOptions()
    • 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(
    • 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 ?? [])
    • 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'
      +      })
      +    )
      +  })
       })
    • apps/desktop/src/hermes.ts
      @@ -869,10 +869,28 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
         })
       }
       
      -export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> {
      +export function getGlobalModelOptions(opts?: {
      +  refresh?: boolean
      +  includeUnconfigured?: boolean
      +  explicitOnly?: boolean
      +}): Promise<ModelOptionsResponse> {
      +  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<ModelOptionsResponse>({
           ...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
         })
       }
    • 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<ModelOptionsResponse>('model.options', params)
         }
       
      -  return getGlobalModelOptions(refresh ? { refresh: true } : undefined)
      +  return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
       }
    • 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
         }
    • 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
       
       
    • 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.
       
    • hermes_cli/web_server.py
      @@ -4284,7 +4284,12 @@ def get_model_info(profile: Optional[str] = None):
       
       
       @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,
    • scripts/release.py
      @@ -90,6 +90,7 @@
           "[redacted:email]": "VolodymyrBg",  # PR #2861 salvage (webhook: drop the unused full request payload from retained _delivery_info entries — up to ~1MB dead weight per delivery for the 1h idempotency TTL)
           "[redacted:email]": "CharmingGroot",  # PR #2794 salvage (email: guard msg_data[0][1] against malformed IMAP fetch structures so one bad response can't abort the batch and permanently lose seen-marked messages; Message-ID domain falls back to localhost when EMAIL_ADDRESS lacks '@')
           "[redacted:email]": "sahibzada-allahyar",  # PR #39227 salvage (desktop: configured terminal.cwd overrides a stale remembered workspace-cwd localStorage value when no session is active; #38855)
      +    "[redacted:email]": "rarf",  # PR #56966 salvage (desktop chat model picker: hide implicitly discovered providers unless explicitly configured)
           "[redacted:email]": "plcunha",  # PR #55300 salvage (gateway: record child gateway peer metadata after a compression session-id rotation and repoint stale sessions.json compression-parent entries to the recovered live child; consolidated in the compression-routing-integrity salvage)
           "[redacted:email]": "jakepresent",  # PR #55721 salvage (gateway: identity-guard stale in-flight compression splits — a late run may publish its compressed child only if its run generation is still current and the session key still points at the run's original parent, so an old run can't overwrite a newer /new or moved binding)
           "[redacted:email]": "gumclaw",  # PR #57322 salvage (gateway: close per-delivery webhook sessions on completion so prune_sessions can reap them — fixes unbounded state.db growth from unprunable ended_at=NULL webhook rows)
    • tests/hermes_cli/test_auth_provider_gate.py
      @@ -82,3 +82,92 @@ def test_claude_code_oauth_token_does_not_count_as_explicit(tmp_path, monkeypatc
       
           from hermes_cli.auth import is_provider_explicitly_configured
           assert is_provider_explicitly_configured("anthropic") is False
      +
      +
      +def test_ambient_pool_source_does_not_count_as_explicit(tmp_path, monkeypatch):
      +    """gh_cli-seeded Copilot pool entries are ambient, not explicit config (#56974)."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
      +    monkeypatch.delenv("GH_TOKEN", raising=False)
      +    monkeypatch.delenv("GITHUB_TOKEN", raising=False)
      +    _write_auth_store(tmp_path, {
      +        "version": 1,
      +        "providers": {},
      +        "active_provider": None,
      +        "credential_pool": {
      +            "copilot": [{
      +                "id": "abc123",
      +                "source": "gh_cli",
      +                "auth_type": "api_key",
      +                "access_token": "ghu_sometoken",
      +            }],
      +        },
      +    })
      +
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +    assert is_provider_explicitly_configured("copilot") is False
      +
      +
      +def test_explicit_pool_source_counts_as_explicit(tmp_path, monkeypatch):
      +    """manual / device_code / PKCE pool entries reflect explicit Hermes flows."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    _write_auth_store(tmp_path, {
      +        "version": 1,
      +        "providers": {},
      +        "active_provider": None,
      +        "credential_pool": {
      +            "anthropic": [{
      +                "id": "def456",
      +                "source": "manual:key-1",
      +                "auth_type": "api_key",
      +                "access_token": "sk-ant-api03-key",
      +            }],
      +        },
      +    })
      +
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +    assert is_provider_explicitly_configured("anthropic") is True
      +
      +
      +def test_stale_env_pool_entry_does_not_count_when_var_unset(tmp_path, monkeypatch):
      +    """An env-seeded pool entry left in auth.json after the env var was removed
      +    must not mark the provider configured (#55790): the picker showed removed
      +    providers forever because the record existed even though no secret resolves."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
      +    _write_auth_store(tmp_path, {
      +        "version": 1,
      +        "providers": {},
      +        "active_provider": None,
      +        "credential_pool": {
      +            "deepseek": [{
      +                "id": "aaa111",
      +                "source": "env:DEEPSEEK_API_KEY",
      +                "auth_type": "api_key",
      +            }],
      +        },
      +    })
      +
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +    assert is_provider_explicitly_configured("deepseek") is False
      +
      +
      +def test_env_pool_entry_counts_when_var_still_resolves(tmp_path, monkeypatch):
      +    """The same env-seeded pool entry IS explicit while the var still resolves."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    monkeypatch.setenv("DEEPSEEK_API_KEY", "[redacted:openai-key]")
      +    _write_auth_store(tmp_path, {
      +        "version": 1,
      +        "providers": {},
      +        "active_provider": None,
      +        "credential_pool": {
      +            "deepseek": [{
      +                "id": "aaa111",
      +                "source": "env:DEEPSEEK_API_KEY",
      +                "auth_type": "api_key",
      +            }],
      +        },
      +    })
      +
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +    assert is_provider_explicitly_configured("deepseek") is True
    • 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 ──────────────────────────────────────────────────────
       
       
    • tests/hermes_cli/test_web_server_profile_unification.py
      @@ -316,6 +316,36 @@ def test_model_options_unknown_profile_404(self, client, isolated_profiles):
               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)."""
    • 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
       # ---------------------------------------------------------------------------
    • 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,
    • ui-tui/src/components/modelPicker.tsx
      @@ -60,7 +60,12 @@ export function ModelPicker({
         useEffect(() => {
           gw.request<ModelOptionsResponse>('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<ModelOptionsResponse>(raw)
    • 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,
                 },
               );
       
    • 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: "[redacted:secret-assignment]" }),
           );
         });
      @@ -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: "[redacted:secret-assignment]" }),
           );
         });
    • 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<ModelOptionsResponse>(`/api/model/options${suffix}`);
         },
  • #60757 fix(desktop): keep configured MoA presets in model picker

    merged to main

    files: hermes_cli/inventory.py, tests/hermes_cli/test_inventory.py

    Show the diff
    • hermes_cli/inventory.py
      @@ -340,13 +340,61 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list
               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).
      +            # current provider (handled above) or the user explicitly wrote an
      +            # enabled MoA preset into config.yaml. Use raw config so the
      +            # DEFAULT_CONFIG preset does not make every desktop picker show MoA.
      +            if _raw_config_has_enabled_moa_preset():
      +                kept.append(row)
                   continue
               if is_provider_explicitly_configured(slug):
                   kept.append(row)
           return kept
       
       
      +def _raw_config_has_enabled_moa_preset() -> bool:
      +    """Return True when the user's raw config explicitly enables MoA.
      +
      +    ``load_config()`` includes ``DEFAULT_CONFIG["moa"].presets.default`` for
      +    everyone. Explicit-only model pickers must not treat that default as a user
      +    choice, but they should keep MoA visible once the user has saved at least
      +    one enabled preset (or an older flat MoA config) in their own config.yaml.
      +    """
      +    try:
      +        from hermes_cli.config import read_raw_config
      +
      +        raw = read_raw_config()
      +    except Exception:
      +        return False
      +
      +    if not isinstance(raw, dict):
      +        return False
      +    moa = raw.get("moa")
      +    if not isinstance(moa, dict):
      +        return False
      +
      +    presets = moa.get("presets")
      +    if isinstance(presets, dict):
      +        for name, preset in presets.items():
      +            if not str(name or "").strip():
      +                continue
      +            if not isinstance(preset, dict):
      +                return True
      +            if preset.get("enabled", True):
      +                return True
      +        return False
      +
      +    legacy_keys = {
      +        "reference_models",
      +        "aggregator",
      +        "reference_temperature",
      +        "aggregator_temperature",
      +        "max_tokens",
      +        "reference_max_tokens",
      +        "fanout",
      +    }
      +    return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True))
      +
      +
       def _apply_picker_hints(rows: list[dict]) -> None:
           """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.
       
    • tests/hermes_cli/test_inventory.py
      @@ -402,6 +402,7 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_
           ctx = _empty_ctx(provider="openai-codex", model="gpt-5.4")
           with (
               _list_auth_returning(rows),
      +        patch("hermes_cli.config.read_raw_config", return_value={}),
               patch(
                   "hermes_cli.auth.is_provider_explicitly_configured",
                   side_effect=lambda slug: slug == "gemini",
      @@ -416,6 +417,43 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_
           ]
       
       
      +def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset():
      +    rows = [
      +        {"slug": "moa", "name": "MoA", "models": ["review"],
      +         "total_models": 1, "is_current": False, "is_user_defined": False,
      +         "source": "virtual"},
      +    ]
      +    ctx = _empty_ctx(provider="openrouter", model="anthropic/claude-opus-4.8")
      +    raw_config = {
      +        "moa": {
      +            "active_preset": "review",
      +            "presets": {
      +                "review": {
      +                    "enabled": True,
      +                    "reference_models": [
      +                        {"provider": "openai-codex", "model": "gpt-5.5"},
      +                    ],
      +                    "aggregator": {
      +                        "provider": "openrouter",
      +                        "model": "anthropic/claude-opus-4.8",
      +                    },
      +                },
      +            },
      +        },
      +    }
      +
      +    with (
      +        _list_auth_returning(rows),
      +        patch("hermes_cli.config.load_config", return_value=raw_config),
      +        patch("hermes_cli.config.read_raw_config", return_value=raw_config),
      +        patch("hermes_cli.auth.is_provider_explicitly_configured", return_value=False),
      +    ):
      +        payload = build_models_payload(ctx, explicit_only=True)
      +
      +    assert [row["slug"] for row in payload["providers"]] == ["moa"]
      +    assert payload["providers"][0]["models"] == ["review"]
      +
      +
       # ─── picker_hints ──────────────────────────────────────────────────────
       
       

6 PRs closed without merging

From the close comments: duplicate 2 · superseded by another PR 2 · closed without comment 1 · other 1

  • #25252 fix(copilot): remove gh auth token fallback to prevent false positive availability · duplicate → #25250, #25246
  • #25254 fix(copilot): remove gh auth token fallback from Copilot auth resolution · superseded by another PR → #25250
  • #25372 feat: model picker favorites/LRU + reasoning sub-picker · other
  • #49102 fix(copilot): validate token scope at seed time, skip dead credential pool entries · closed without comment
  • #51664 fix: add consent gate to copilot provider in _seed_from_singletons · duplicate → #51655, #51652
Show 1 more
  • #61887 fix(moa): keep MoA presets in explicit-only desktop pickers (#61889) · superseded by another PR → 37a4cf9, #60757, #60722, #37, #61889
Closed issues · 11
  • Closed#12655feat(model-picker): add `picker_providers` config to filter which providers appear in /model
  • Closed#25245copilot_auth.py fallback to gh auth token causes false positive for Copilot availability
  • Closed#25363模型选择器 /model 支持置顶常用模型(favorites / LRU)
  • Closed#26386[Feature]: Config option to hide/disable built-in providers from the picker
  • Closed#27554feat: add config key to hide unused providers from model picker
  • Closed#33195/model picker auto-discovers GitHub Copilot from gh auth token without explicit configuration
  • Closed#49002gh auth token auto-promoted to Copilot provider with no scope validation; pollutes model picker with dead entries
  • Closed#56974Desktop chat model picker shows implicitly discovered providers as if they were configured
  • Closed#57886feat: allow disabling/hiding providers in model picker
  • Closed#59483[Feature]: Filter model selector dropdown to only show providers with configured API keys
  • Closed#59733feat: Only show configured providers in model picker, or add hide/disable option
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.04: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#60514, #60757
Closed issues
11
Attempts
6 PRs closed without merging
Fix link checked
10 Jul 2026

For your agent

The same facts, typed.