From 3be1326f8d5e2eafb383e9b165ffbd53a265307f Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 18 Jun 2026 23:16:16 -0400 Subject: [PATCH] feat(desktop): /api/env derives provider key membership from unified catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Keys tab now surfaces every keys-tab provider in provider_catalog() (the `hermes model` universe), synthesizing a card even when the env var has no hand entry in OPTIONAL_ENV_VARS. Closes the drift where openai-api, kilocode, novita, tencent-tokenhub, and copilot were CLI-configurable but invisible in the desktop Providers → API keys tab. Each provider row now carries backend-derived provider/provider_label grouping hints so the desktop can group by the same provider identity the CLI picker uses. Hand OPTIONAL_ENV_VARS prose still wins where present (enrichment, not a gate). Shared non-provider credentials (e.g. tool-category GITHUB_TOKEN) are explicitly not hijacked into a provider card — Copilot uses its provider-owned COPILOT_GITHUB_TOKEN. --- hermes_cli/web_server.py | 105 +++++++++++++++++++++++++--- tests/hermes_cli/test_web_server.py | 42 +++++++++++ 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b0d51e2481e..ddd9b3c3d3d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3971,28 +3971,117 @@ async def update_config(body: ConfigUpdate, profile: Optional[str] = None): raise HTTPException(status_code=500, detail="Internal server error") +def _catalog_provider_env_metadata() -> dict: + """Map provider env vars → desktop card metadata, derived from the catalog. + + Returns ``{env_var: {provider, provider_label, description, url, is_password, + advanced}}`` for every API-key provider in the unified ``provider_catalog()`` + (i.e. the ``hermes model`` universe). This is what lets the desktop Keys tab + render a card for a provider even when its env var was never hand-added to + ``OPTIONAL_ENV_VARS`` — closing the drift where CLI-configurable providers + (openai-api, kilocode, novita, tencent-tokenhub, copilot, …) were missing + from the GUI. + + Hand ``OPTIONAL_ENV_VARS`` prose is layered ON TOP of this in the endpoint; + this only supplies membership + grouping + sensible fallbacks. + """ + try: + from hermes_cli.provider_catalog import provider_catalog + except Exception: + return {} + + # Env vars already declared with a NON-provider category (e.g. the shared + # GITHUB_TOKEN, which is a Skills-Hub "tool" credential) must not be + # promoted into a provider card. Copilot lists GITHUB_TOKEN among its auth + # aliases, but its provider card uses the provider-owned COPILOT_GITHUB_TOKEN. + try: + from hermes_cli.config import OPTIONAL_ENV_VARS as _OPT + except Exception: + _OPT = {} + _non_provider_keys = { + k for k, v in _OPT.items() + if (v or {}).get("category") and (v or {}).get("category") != "provider" + } + + meta: dict = {} + for d in provider_catalog(): + if d.tab != "keys": + continue + # API-key vars: the first is the primary (password) field; any aliases + # are kept as additional password fields so users can clear them too. + for env_var in d.api_key_env_vars: + if env_var in _non_provider_keys: + continue # don't hijack a shared tool/messaging credential + meta.setdefault( + env_var, + { + "provider": d.slug, + "provider_label": d.label, + "description": d.description, + "url": d.signup_url or None, + "is_password": True, + "advanced": False, + "category": "provider", + }, + ) + # Base-URL override is an advanced, non-secret field for the same card. + if d.base_url_env_var: + meta.setdefault( + d.base_url_env_var, + { + "provider": d.slug, + "provider_label": d.label, + "description": f"{d.label} base URL override", + "url": None, + "is_password": False, + "advanced": True, + "category": "provider", + }, + ) + return meta + + @app.get("/api/env") async def get_env_vars(profile: Optional[str] = None): with _profile_scope(profile): env_on_disk = load_env() channel_keys = _channel_managed_env_keys() - result = {} - for var_name, info in OPTIONAL_ENV_VARS.items(): + catalog_meta = _catalog_provider_env_metadata() + + def _row(var_name: str, info: dict) -> dict: value = env_on_disk.get(var_name) - result[var_name] = { + cat_meta = catalog_meta.get(var_name) or {} + # Hand OPTIONAL_ENV_VARS prose wins where present; the catalog fills any + # gaps (description/url) and always supplies provider grouping hints. + return { "is_set": bool(value), "redacted_value": redact_key(value) if value else None, - "description": info.get("description", ""), - "url": info.get("url"), - "category": info.get("category", ""), - "is_password": info.get("password", False), + "description": info.get("description") or cat_meta.get("description", ""), + "url": info.get("url") if info.get("url") is not None else cat_meta.get("url"), + "category": info.get("category") or cat_meta.get("category", ""), + "is_password": info.get("password", cat_meta.get("is_password", False)), "tools": info.get("tools", []), - "advanced": info.get("advanced", False), + "advanced": info.get("advanced", cat_meta.get("advanced", False)), # True when this var is a messaging-platform credential owned by a # Channels page card. The Keys/Env page uses this to hide it and # avoid duplicating the (richer) Channels configuration UI. "channel_managed": var_name in channel_keys, + # Provider grouping hints derived from the unified provider catalog + # so the desktop Keys tab groups by the SAME provider identity the + # CLI `hermes model` picker uses (not desktop-only prefix guesses). + "provider": cat_meta.get("provider", ""), + "provider_label": cat_meta.get("provider_label", ""), } + + result = {} + for var_name, info in OPTIONAL_ENV_VARS.items(): + result[var_name] = _row(var_name, info) + # Synthesize rows for catalog provider env vars that have no hand entry in + # OPTIONAL_ENV_VARS — these are the providers that were CLI-configurable but + # invisible in the desktop app until now. + for var_name in catalog_meta: + if var_name not in result: + result[var_name] = _row(var_name, {}) return result diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 7416ec0b87a..8faf1b8823c 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1299,6 +1299,48 @@ class TestWebServerEndpoints: for key, info in data.items(): assert info["channel_managed"] is (key in channel_keys) + def test_get_env_vars_surfaces_catalog_providers(self): + """Every keys-tab provider in the unified catalog must appear in /api/env + as a provider card, even when it has no hand entry in OPTIONAL_ENV_VARS. + + Regression for the GUI⇄CLI drift: openai-api, kilocode, novita, + tencent-tokenhub, copilot were configurable via `hermes model` but + invisible in the desktop Providers → API keys tab. + """ + from hermes_cli.provider_catalog import provider_catalog + + data = self.client.get("/api/env").json() + for d in provider_catalog(): + if d.tab != "keys" or not d.api_key_env_vars: + continue + # The PRIMARY credential var must surface as this provider's card. + # (Shared aliases like GITHUB_TOKEN are intentionally left on their + # existing tool category and not hijacked — see the copilot test.) + primary = d.api_key_env_vars[0] + assert primary in data, f"{primary} ({d.slug}) missing from /api/env" + info = data[primary] + assert info["category"] == "provider" + assert info["provider"] == d.slug + assert info["provider_label"] == d.label + + def test_get_env_vars_provider_rows_carry_grouping_hints(self): + """Provider env rows expose the backend `provider`/`provider_label` the + desktop Keys tab groups by (so it no longer relies on prefix guesses).""" + data = self.client.get("/api/env").json() + # OPENAI_API_KEY is a hand-listed protected var AND a catalog provider; + # it must come back tagged to the openai-api provider. + assert data["OPENAI_API_KEY"]["provider"] == "openai-api" + assert data["OPENAI_API_KEY"]["category"] == "provider" + + def test_get_env_vars_copilot_uses_provider_token_not_shared_github_token(self): + """Copilot surfaces as its own provider card via COPILOT_GITHUB_TOKEN; + the shared GITHUB_TOKEN keeps its existing (tool) category.""" + data = self.client.get("/api/env").json() + assert data["COPILOT_GITHUB_TOKEN"]["provider"] == "copilot" + assert data["COPILOT_GITHUB_TOKEN"]["category"] == "provider" + # Shared GITHUB_TOKEN must NOT be hijacked into the copilot provider card. + assert data.get("GITHUB_TOKEN", {}).get("provider", "") != "copilot" + def test_platform_scoped_messaging_env_vars_are_channel_managed(self): from hermes_cli.web_server import ( _MESSAGING_KEYS_PAGE_KEYS,