feat(desktop): Accounts tab derives membership from unified provider catalog

/api/providers/oauth now unions the explicit hand-tuned OAuth cards
(_OAUTH_PROVIDER_CATALOG — bespoke flow/status/cli, plus the api-key Anthropic
PKCE card and synthetic claude-code row) with every accounts-tab provider in
provider_catalog(). Any OAuth/external provider in the `hermes model` universe
now appears automatically, closing the drift where google-gemini-cli and
copilot-acp had no Accounts card despite being CLI-configurable.

Adds read-only status cards for google-gemini-cli (via existing
get_gemini_oauth_auth_status) and copilot-acp (managed-by-CLI, like claude-code).
DELETE handler routes through the same _build_oauth_catalog() builder.

Parity test asserts the Accounts tab offers every accounts-tab catalog provider
as an invariant.
This commit is contained in:
Austin Pickett 2026-06-18 23:21:23 -04:00 committed by Teknium
parent 3be1326f8d
commit 60dfa0f31b
2 changed files with 139 additions and 2 deletions

View file

@ -5550,6 +5550,40 @@ def _claude_code_only_status() -> Dict[str, Any]:
return {"logged_in": False, "source": None}
def _gemini_cli_status() -> Dict[str, Any]:
"""Status for the google-gemini-cli OAuth provider (Code Assist login)."""
try:
from hermes_cli import auth as hauth
raw = hauth.get_gemini_oauth_auth_status()
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {
"logged_in": bool(raw.get("logged_in")),
"source": raw.get("source") or "google_oauth",
"source_label": raw.get("email") or raw.get("auth_file") or "Google Code Assist",
"token_preview": _truncate_token(raw.get("api_key")),
"expires_at": None,
"has_refresh_token": True,
}
def _copilot_acp_status() -> Dict[str, Any]:
"""Status for copilot-acp — credentials are owned by the Copilot CLI.
There is no cheap programmatic credential probe for the ACP subprocess, so
this is a read-only "managed by the Copilot CLI" card (like claude-code):
Hermes never claims a login state it can't verify.
"""
return {
"logged_in": False,
"source": "copilot_cli",
"source_label": "Managed by the GitHub Copilot CLI",
"token_preview": None,
"expires_at": None,
"has_refresh_token": False,
}
# Provider catalog. The order matters — it's how we render the UI list.
# ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard
# fallback while Phase 2 (in-browser flows) isn't built yet.
@ -5606,6 +5640,22 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
},
{
"id": "google-gemini-cli",
"name": "Google Gemini (OAuth + Code Assist)",
"flow": "external",
"cli_command": "hermes auth add google-gemini-cli",
"docs_url": "https://ai.google.dev/gemini-api/docs",
"status_fn": _gemini_cli_status,
},
{
"id": "copilot-acp",
"name": "GitHub Copilot (ACP)",
"flow": "external",
"cli_command": "copilot /login",
"docs_url": "https://docs.github.com/en/copilot",
"status_fn": _copilot_acp_status,
},
# ── Anthropic / Claude entries sit at the bottom: the API-key path
# first, then the subscription OAuth path (which only works with extra
# usage credits on top of a Claude Max plan — see disclaimer in name).
@ -5735,6 +5785,56 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str,
return None
def _build_oauth_catalog() -> list[Dict[str, Any]]:
"""Build the Accounts-tab provider list.
MEMBERSHIP is the union of:
1. ``_OAUTH_PROVIDER_CATALOG`` the explicit, hand-tuned cards that carry
bespoke flow / status_fn / cli_command (including the api-key Anthropic
PKCE card and the synthetic claude-code subscription row, which are not
catalog providers), and
2. every accounts-tab provider in the unified ``provider_catalog()`` (the
``hermes model`` universe) so any OAuth/external provider added as a
plugin appears automatically, with sensible defaults, even if no
explicit card was written for it.
The explicit catalog wins on metadata; the unified catalog guarantees we
never silently drop a provider the CLI picker offers. Order: explicit cards
first (their curated order), then any catalog-only providers appended in
``hermes model`` order.
"""
rows: list[Dict[str, Any]] = []
seen: set[str] = set()
# 1. Explicit hand-tuned cards (authoritative metadata + curated order).
for entry in _OAUTH_PROVIDER_CATALOG:
if entry["id"] in seen:
continue
seen.add(entry["id"])
rows.append(dict(entry))
# 2. Catalog accounts-providers not already covered — keeps the Accounts tab
# in lockstep with the `hermes model` universe (zero-edit for new plugins).
try:
from hermes_cli.provider_catalog import provider_catalog
for d in provider_catalog():
if d.tab != "accounts" or d.slug in seen:
continue
seen.add(d.slug)
rows.append({
"id": d.slug,
"name": d.label,
"flow": "external",
"cli_command": f"hermes auth add {d.slug}",
"docs_url": d.signup_url or "",
"status_fn": None,
})
except Exception:
pass
return rows
@app.get("/api/providers/oauth")
async def list_oauth_providers(profile: Optional[str] = None):
"""Enumerate every OAuth-capable LLM provider with current status.
@ -5754,10 +5854,14 @@ async def list_oauth_providers(profile: Optional[str] = None):
token_preview last N chars of the token, never the full token
expires_at ISO timestamp string or null
has_refresh_token bool
Membership is derived from the unified provider_catalog() so this stays in
sync with the `hermes model` picker; _OAUTH_OVERRIDES supplies per-provider
flow/status/cli metadata.
"""
with _profile_scope(profile):
providers = []
for p in _OAUTH_PROVIDER_CATALOG:
for p in _build_oauth_catalog():
status = _resolve_provider_status(p["id"], p.get("status_fn"))
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
providers.append({
@ -5784,7 +5888,7 @@ async def disconnect_oauth_provider(
_require_token(request)
with _profile_scope(profile):
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
catalog_by_id = {p["id"]: p for p in _build_oauth_catalog()}
provider = catalog_by_id.get(provider_id)
if provider is None:
raise HTTPException(

View file

@ -470,6 +470,39 @@ def test_xai_oauth_listed_as_loopback_flow():
assert "grok" in providers["xai-oauth"]["name"].lower()
def test_accounts_offers_every_oauth_provider_from_catalog():
"""PARITY CONTRACT: every accounts-tab provider in the unified catalog (the
`hermes model` universe) must be offered by /api/providers/oauth. This keeps
the desktop Accounts tab in lockstep with the CLI picker no provider the
CLI can sign into may be missing from the GUI.
"""
from hermes_cli.provider_catalog import provider_catalog
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
offered = {p["id"] for p in resp.json()["providers"]}
for d in provider_catalog():
if d.tab == "accounts":
assert d.slug in offered, (
f"{d.slug} is an accounts-tab provider in `hermes model` but is "
f"missing from the desktop Accounts tab (/api/providers/oauth)"
)
def test_gemini_cli_and_copilot_acp_now_in_accounts():
"""Regression: google-gemini-cli and copilot-acp were canonical providers the
CLI could configure, but had no Accounts card (the reported GUI/CLI drift).
"""
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
assert "google-gemini-cli" in providers
assert "copilot-acp" in providers
# copilot-acp is managed by an external CLI: read-only card, not auto-removable.
assert providers["copilot-acp"]["flow"] == "external"
assert providers["copilot-acp"]["disconnectable"] is False
def test_oauth_catalog_marks_external_providers_not_disconnectable():
"""External CLI credentials are visible in Accounts but cannot be removed by Hermes."""
resp = client.get("/api/providers/oauth", headers=HEADERS)