mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
feat(desktop): /api/env derives provider key membership from unified catalog
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.
This commit is contained in:
parent
054b8c82fd
commit
3be1326f8d
2 changed files with 139 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue