From 71346fac63a8d13653bbcad2841850148a68dd47 Mon Sep 17 00:00:00 2001 From: Adam Durham Date: Sun, 26 Jul 2026 14:08:37 -0500 Subject: [PATCH] fix: desktop model picker hid Anthropic despite valid Claude Code credentials _filter_explicit_provider_rows() in hermes_cli/inventory.py (the desktop-only explicit_only=True picker filter) re-checked every row against is_provider_explicitly_configured(), which deliberately excludes CLAUDE_CODE_OAUTH_TOKEN / external Claude Code credential files (PR #4210, prevents aux tasks from silently burning subscription tokens). That gate is correct for aux-task consumption, but was also silencing the picker's display of a provider list_authenticated_providers() already surfaces for both CLI and desktop via the exact same credential check -- so the CLI's /model picker showed Anthropic while the desktop's did not, on identical credentials. Confirmed the premise directly: list_authenticated_providers() in hermes_cli/model_switch.py already has this exact fallback (line ~2126, `if not has_creds and hermes_slug == "anthropic":` checking read_hermes_oauth_credentials()/read_claude_code_credentials() from agent/anthropic_adapter.py, with a comment citing the same PR #4210 rationale) -- this fix extends an already-established, already-documented upstream pattern to the desktop-only filter that wasn't updated to match, rather than inventing new credential-detection logic. Add a narrow carve-out: when is_provider_explicitly_configured("anthropic") is False, also check for valid external Claude Code / Hermes-PKCE credentials before dropping the row. is_provider_explicitly_configured() itself is untouched, so aux-task gating is unaffected -- this only widens what the desktop picker displays. Tests: tests/hermes_cli/test_inventory.py -- 46 passed. Also ran test_inventory_pricing.py, test_model_switch_custom_providers.py, test_model_switch_configured_provider_routing.py to check for regressions on adjacent credential-detection paths: 117 passed total, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/inventory.py | 41 +++++++++++++++++++++++ tests/hermes_cli/test_inventory.py | 54 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 4e95665d481f8..c8d34ecb201c4 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -610,9 +610,50 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list continue if is_provider_explicitly_configured(slug): kept.append(row) + continue + if slug == "anthropic" and _has_valid_external_anthropic_credentials(): + # is_provider_explicitly_configured() deliberately excludes + # CLAUDE_CODE_OAUTH_TOKEN / external Claude Code credential files + # so aux tasks never silently burn the user's Claude Code + # subscription tokens without an explicit Hermes-side choice + # (PR #4210). That gate must stay untouched. + # + # But list_authenticated_providers() (used by BOTH the CLI + # `/model` picker and the desktop picker, upstream of this + # explicit-only filter) already surfaces anthropic whenever + # valid external Claude Code / Hermes-PKCE credentials exist — + # that's why `hermes model` already lists it. The desktop's + # explicit-only filter was silently dropping that same row, + # so the two surfaces disagreed. Keep the row visible here too + # (display only — is_provider_explicitly_configured is untouched, + # so aux-task gating and credential-pool behavior don't change). + kept.append(row) return kept +def _has_valid_external_anthropic_credentials() -> bool: + """True when Claude Code CLI or Hermes-managed OAuth creds are usable. + + Mirrors the has_creds fallback in ``list_authenticated_providers()`` so + the desktop's explicit-only picker filter agrees with what the CLI + `/model` picker already shows for Anthropic. Display-only check — does + NOT affect ``is_provider_explicitly_configured()`` or aux-task gating. + """ + try: + from agent.anthropic_adapter import ( + read_claude_code_credentials, + read_hermes_oauth_credentials, + ) + hermes_creds = read_hermes_oauth_credentials() + cc_creds = read_claude_code_credentials() + return bool( + (hermes_creds and hermes_creds.get("accessToken")) + or (cc_creds and cc_creds.get("accessToken")) + ) + except Exception: + return False + + def _raw_config_has_enabled_moa_preset() -> bool: """Return True when the user's raw config explicitly enables MoA. diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index f85a5edf7b198..9fb26fb055b7e 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -208,6 +208,60 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_ ] +def test_explicit_only_keeps_anthropic_row_when_claude_code_credentials_valid(): + """Desktop's explicit-only picker must show Anthropic whenever valid + external Claude Code CLI / Hermes-PKCE credentials exist — matching what + `list_authenticated_providers()` (and thus the CLI `/model` picker) + already surfaces. is_provider_explicitly_configured() intentionally + excludes CLAUDE_CODE_OAUTH_TOKEN so aux-task gating (#4210) is untouched; + this only affects picker row visibility.""" + rows = [ + {"slug": "anthropic", "name": "Anthropic", "models": ["claude-sonnet-5"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "built-in"}, + {"slug": "gemini", "name": "Gemini", "models": ["gemini-2.5-pro"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "built-in"}, + ] + ctx = _empty_ctx() + with ( + _list_auth_returning(rows), + patch("hermes_cli.config.read_raw_config", return_value={}), + patch("hermes_cli.auth.is_provider_explicitly_configured", return_value=False), + patch( + "agent.anthropic_adapter.read_claude_code_credentials", + return_value={"accessToken": "tok", "refreshToken": "rtok"}, + ), + patch( + "agent.anthropic_adapter.read_hermes_oauth_credentials", + return_value=None, + ), + ): + payload = build_models_payload(ctx, explicit_only=True) + + assert [row["slug"] for row in payload["providers"]] == ["anthropic"] + + +def test_explicit_only_drops_anthropic_row_without_external_credentials(): + """Without valid Claude Code / Hermes-PKCE credentials, the ambient + anthropic row stays hidden from the explicit-only picker as before.""" + rows = [ + {"slug": "anthropic", "name": "Anthropic", "models": ["claude-sonnet-5"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "built-in"}, + ] + ctx = _empty_ctx() + with ( + _list_auth_returning(rows), + patch("hermes_cli.config.read_raw_config", return_value={}), + patch("hermes_cli.auth.is_provider_explicitly_configured", return_value=False), + patch("agent.anthropic_adapter.read_claude_code_credentials", return_value=None), + patch("agent.anthropic_adapter.read_hermes_oauth_credentials", return_value=None), + ): + payload = build_models_payload(ctx, explicit_only=True) + + assert payload["providers"] == [] + # ─── picker_hints ──────────────────────────────────────────────────────