mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
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:
parent
3be1326f8d
commit
60dfa0f31b
2 changed files with 139 additions and 2 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue