refactor(auth): copilot registry rows come from the plugin profiles

The core PROVIDER_REGISTRY rows and alias-map rows for copilot / copilot-acp are
deleted; the plugin->registry mirror (auth_plugin_providers) now supplies them
from the profiles, so with the plugin absent copilot is an unknown provider and
GH_TOKEN can no longer auto-detect it.

Generic profile fields (providers/base.py, I2-auth block) fill the slots the
mirror lacked:
- registry_auth_type: row auth_type when credentials resolve differently from
  the setup flow auth_type names (copilot: "copilot" flow, api_key row)
- base_url_env_var: base-URL override env var not listed in env_vars
- registry_after: keep the row's place in priority-ordered scans (aux api-key
  chain, env auto-detect)

The mirror also writes alias keys pointing at a plugin's row; scans now see each
provider once (canonical_registry_rows): env auto-detect (an ambient plugin was
auto-detected through its alias key), `hermes auth list` / provider pickers, and
credential_lifecycle env-var -> provider mapping (seeded pools under alias names).

DEFAULT_GITHUB_MODELS_BASE_URL / DEFAULT_COPILOT_ACP_BASE_URL had no other users;
the plugins carry the literal base URLs.
This commit is contained in:
Teknium committed 2026-10-25 19:32:48 -07:00
1 parent ef4e50ebd3
commit 4a18279c62
12 files changed
+191 -37

No files matched your search

+7 -15
View File
@@ -95,8 +95,7 @@ from hermes_cli.auth_constants import (
DEFAULT_XAI_OAUTH_BASE_URL, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_SCOPE,
MINIMAX_OAUTH_GLOBAL_BASE, MINIMAX_OAUTH_CN_BASE, MINIMAX_OAUTH_GLOBAL_INFERENCE,
MINIMAX_OAUTH_CN_INFERENCE, MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, DEFAULT_QWEN_BASE_URL,
DEFAULT_GITHUB_MODELS_BASE_URL, DEFAULT_COPILOT_ACP_BASE_URL, DEFAULT_OLLAMA_CLOUD_BASE_URL,
DEFAULT_ACTUAL_BASE_URL, DEFAULT_ACTUAL_LOCAL_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL,
DEFAULT_OLLAMA_CLOUD_BASE_URL, DEFAULT_ACTUAL_BASE_URL, DEFAULT_ACTUAL_LOCAL_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL,
STEPFUN_STEP_PLAN_CN_BASE_URL, CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL,
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_SCOPE,
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
@@ -181,11 +180,6 @@ _REGISTRY_ROWS: tuple[Any, ...] = (
inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL),
ProviderConfig("qwen-oauth", "Qwen OAuth", "oauth_external", inference_base_url=DEFAULT_QWEN_BASE_URL),
("lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", ("LM_API_KEY",), "LM_BASE_URL"),
("copilot", "GitHub Copilot", DEFAULT_GITHUB_MODELS_BASE_URL,
("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), "COPILOT_API_BASE_URL"),
ProviderConfig(
"copilot-acp", "GitHub Copilot ACP", "external_process",
inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, base_url_env_var="COPILOT_ACP_BASE_URL"),
("gemini", "Google AI Studio", "https://generativelanguage.googleapis.com/v1beta",
("GOOGLE_API_KEY", "GEMINI_API_KEY"), "GEMINI_BASE_URL"),
("zai", "Z.AI / GLM", "https://api.z.ai/api/paas/v4",
@@ -260,7 +254,8 @@ from hermes_cli.config import (
# Plugin profiles (plugins/model-providers/<name>/) are mirrored into PROVIDER_REGISTRY with the
# auth_type they declare; the mirror lives in the sibling so it can be re-run after discovery.
from hermes_cli.auth_plugin_providers import (
get_plugin_oauth_auth_status, registry_lookup as _registry_lookup, sync_plugin_provider_registry)
canonical_registry_rows as _canonical_registry_rows, get_plugin_oauth_auth_status,
registry_lookup as _registry_lookup, sync_plugin_provider_registry)
sync_plugin_provider_registry()
@@ -1227,7 +1222,7 @@ def _config_selects_provider(normalized: str) -> bool:
cfg = load_config()
if _slot_selects(cfg.get("model"), normalized):
return True
# ``auxiliary.<task>.provider: copilot`` selects the provider for that task the same way a MoA
# ``auxiliary.<task>.provider: <id>`` selects the provider for that task the same way a MoA
# slot does — without this the seeder treats the credential as merely discovered (#114740).
aux_cfg = cfg.get("auxiliary")
if isinstance(aux_cfg, dict) and any(_slot_selects(s, normalized) for s in aux_cfg.values()):
@@ -1449,9 +1444,6 @@ _PROVIDER_ALIASES: dict[str, str] = {
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
"alibaba_coding_plan": "alibaba-coding-plan",
"claude": "anthropic", "claude-code": "anthropic",
"github": "copilot", "github-copilot": "copilot",
"github-models": "copilot", "github-model": "copilot",
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
"opencode": "opencode-zen", "zen": "opencode-zen",
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth",
@@ -1607,7 +1599,7 @@ def _env_key_auto_detected(
from providers import list_providers
ambient = {p.name for p in list_providers() if p.ambient_credentials} # never per-pid lookups: a miss walks the ladder
for pid, pconfig in PROVIDER_REGISTRY.items():
for pid, pconfig in _canonical_registry_rows():
if pconfig.auth_type != "api_key" or pid in _NO_AUTO_DETECT_PROVIDERS or pid in ambient:
continue
for env_var in pconfig.api_key_env_vars:
@@ -2096,8 +2088,8 @@ def _external_process_spec(
pconfig: ProviderConfig) -> tuple[str, list[str], str, Optional[str], tuple[str, ...]]:
"""``(command, args, base_url, resolved_command, command_env_vars)`` for an ACP provider.
Launch details come from the provider's own profile (copilot-acp: HERMES_COPILOT_ACP_COMMAND /
COPILOT_CLI_PATH / HERMES_COPILOT_ACP_ARGS), so out-of-tree providers describe their binary."""
Launch details come from the provider's own profile (``process_command`` and its env overrides),
so out-of-tree providers describe their binary."""
base_url = _provider_env_base_url(pconfig) or pconfig.inference_base_url
try:
from providers import get_provider_profile as _get_provider_profile
+5 -5
View File
@@ -22,7 +22,7 @@ from agent.credential_pool_admin import CredentialNotSavedError
import hermes_cli.auth as auth_mod
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.auth_plugin_providers import (
dispatch_plugin_auth, is_refreshable_oauth_provider, plugin_missing_auth_handler_error)
canonical_registry_rows, dispatch_plugin_auth, is_refreshable_oauth_provider, plugin_missing_auth_handler_error)
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.secret_prompt import masked_secret_prompt
@@ -141,7 +141,7 @@ def _is_known_provider(provider: str, configured_provider: dict | None) -> bool:
def _unknown_provider_exit(provider: str) -> SystemExit:
"""Did-you-mean over the known provider ids plus the two commands that list/pick them."""
import difflib
known = sorted(set(PROVIDER_REGISTRY) | {"openrouter"}
known = sorted({pid for pid, _ in canonical_registry_rows()} | {"openrouter"}
| {entry["name"] for entry in _get_custom_provider_entries()})
close = difflib.get_close_matches(provider, known, n=3, cutoff=0.5)
hint = f" Did you mean {', '.join(close)}?" if close else ""
@@ -356,7 +356,7 @@ def _add_nous_oauth_credential(args, provider: str) -> PooledCredential:
def _unsuppress_provider_sources(provider: str) -> None:
"""Clear ALL suppressions for this provider — re-adding a credential is a strong signal the
user wants auth re-enabled. Covers env:* (shell-exported vars), gh_cli (copilot), claude_code,
user wants auth re-enabled. Covers env:* (shell-exported vars), provider CLI tokens, claude_code,
qwen-cli, device_code (codex), etc. — one consistent re-engagement pattern."""
try:
suppressed = auth_mod._load_auth_store().get("suppressed_sources", {})
@@ -524,7 +524,7 @@ def auth_list_command(args) -> None:
else:
credential_pool = auth_mod._load_auth_store().get("credential_pool")
providers = sorted({
*PROVIDER_REGISTRY.keys(), "openrouter", *list_custom_pool_providers(),
*(pid for pid, _ in canonical_registry_rows()), "openrouter", *list_custom_pool_providers(),
*(e["provider_key"] for e in _get_custom_provider_entries() if e["provider_key"]),
*(credential_pool.keys() if isinstance(credential_pool, dict) else ())})
for provider in providers:
@@ -810,7 +810,7 @@ def _interactive_auth() -> None:
def _pick_provider(prompt: str = "Provider") -> str:
"""Prompt for a provider name with auto-complete hints."""
from providers import unlisted_provider_names
known = sorted((set(PROVIDER_REGISTRY) - unlisted_provider_names()) | {"openrouter"})
known = sorted(({pid for pid, _ in canonical_registry_rows()} - unlisted_provider_names()) | {"openrouter"})
custom_display = [entry["name"] for entry in _get_custom_provider_entries()]
print(f"\nKnown providers: {', '.join(known)}")
if custom_display:
-2
View File
@@ -82,8 +82,6 @@ MINIMAX_OAUTH_GLOBAL_INFERENCE = "https://api.minimax.io/anthropic"
MINIMAX_OAUTH_CN_INFERENCE = "https://api.minimaxi.com/anthropic"
MINIMAX_OAUTH_REFRESH_SKEW_SECONDS = 60
DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1"
DEFAULT_ACTUAL_BASE_URL = "https://api.actual.inc/v1"
DEFAULT_ACTUAL_LOCAL_BASE_URL = "http://127.0.0.1:8080/v1"
+36 -8
View File
@@ -22,10 +22,10 @@ from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# Providers handled outside the registry: copilot/kimi/zai have bespoke token refresh in auth.py;
# Providers handled outside the registry: kimi/zai have bespoke token refresh in auth.py;
# openrouter/custom are aggregator/user-supplied and runtime_provider relies on
# ``openrouter not in PROVIDER_REGISTRY``.
_REGISTRY_PLUGIN_SKIP = frozenset({"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"})
_REGISTRY_PLUGIN_SKIP = frozenset({"kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"})
PLUGIN_AUTH_ACTIONS = ("add", "status", "logout", "refresh")
@@ -52,17 +52,37 @@ def register_plugin_provider(pp: Any) -> None:
if pp.name in _REGISTRY_PLUGIN_SKIP:
return
if pp.auth_type == "api_key":
auth_type = registry_auth_type(pp)
if auth_type == "api_key":
if not pp.env_vars:
return
pconfig = _api_key_provider(pp.name, pp.display_name or pp.name, pp.base_url, *_api_key_env_fields(pp))
key_vars, url_var = _api_key_env_fields(pp)
pconfig = _api_key_provider(pp.name, pp.display_name or pp.name, pp.base_url, key_vars,
pp.base_url_env_var or url_var)
else:
pconfig = ProviderConfig(pp.name, pp.display_name or pp.name, pp.auth_type, inference_base_url=pp.base_url)
PROVIDER_REGISTRY[pp.name] = pconfig
pconfig = ProviderConfig(pp.name, pp.display_name or pp.name, auth_type, inference_base_url=pp.base_url,
base_url_env_var=pp.base_url_env_var)
_insert_row(PROVIDER_REGISTRY, pconfig, pp.registry_after)
PLUGIN_MIRRORED_PROVIDERS.add(pp.name)
mirror_aliases(pconfig, pp)
def registry_auth_type(pp: Any) -> str:
"""The ``auth_type`` *pp*'s registry row carries (``registry_auth_type`` wins over ``auth_type``)."""
return pp.registry_auth_type or pp.auth_type
def _insert_row(registry: dict, pconfig: Any, after: str) -> None:
"""Add *pconfig* right after the *after* row (in place: callers hold the dict), else append."""
if after not in registry:
registry[pconfig.id] = pconfig
return
rows = list(registry.items())
at = [key for key, _ in rows].index(after) + 1
registry.clear()
registry.update(rows[:at] + [(pconfig.id, pconfig)] + rows[at:])
def _user_owns_alias(pp: Any, alias: str) -> bool:
"""True when *alias* resolves to the ``$HERMES_HOME`` plugin *pp* in the ``providers`` layer."""
try:
@@ -98,7 +118,7 @@ def override_registry_row(pconfig: Any, pp: Any) -> None:
pconfig.name = pp.display_name
if pp.base_url:
pconfig.inference_base_url = pp.base_url
if pp.auth_type == "api_key" == pconfig.auth_type and pp.env_vars:
if registry_auth_type(pp) == "api_key" == pconfig.auth_type and pp.env_vars:
pconfig.api_key_env_vars, url_var = _api_key_env_fields(pp)
if url_var:
pconfig.base_url_env_var = url_var
@@ -137,6 +157,14 @@ def sync_plugin_provider_registry() -> int:
return added
def canonical_registry_rows() -> list[tuple[str, Any]]:
"""``PROVIDER_REGISTRY`` items in order, minus the alias keys :func:`mirror_aliases` points at a
plugin's row, so a scan or listing sees each provider once, under its own id."""
from hermes_cli.auth import PROVIDER_REGISTRY
return [(pid, pconfig) for pid, pconfig in PROVIDER_REGISTRY.items() if pid == pconfig.id]
def registry_lookup(provider_id: str) -> Optional[Any]:
"""``PROVIDER_REGISTRY.get`` that re-syncs plugin profiles on a miss."""
from hermes_cli.auth import PROVIDER_REGISTRY
@@ -209,7 +237,7 @@ def plugin_missing_auth_handler_error(provider: str, action: str) -> Optional[Sy
if provider not in PLUGIN_MIRRORED_PROVIDERS:
return None
profile = plugin_profile(provider)
if profile is None or profile.auth_type == "api_key" or plugin_auth_handler(provider) is not None:
if profile is None or registry_auth_type(profile) == "api_key" or plugin_auth_handler(provider) is not None:
return None
return SystemExit(
f"Provider '{provider}' declares auth_type '{profile.auth_type}' but its plugin ships no "
+3 -2
View File
@@ -21,11 +21,12 @@ __all__ = [
def _providers_for_env_var(env_var: str) -> list[str]:
"""Provider ids whose registered api_key_env_vars include ``env_var``."""
try:
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.auth_plugin_providers import canonical_registry_rows
rows = canonical_registry_rows()
except Exception:
return []
hits: list[str] = []
for pid, cfg in PROVIDER_REGISTRY.items():
for pid, cfg in rows:
try:
if env_var in (cfg.api_key_env_vars or ()):
hits.append(pid)
@@ -11,6 +11,7 @@ from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
from .auth_rows import AUTH_ROW_FIELDS
from .setup import SETUP_FIELDS
@@ -86,7 +87,7 @@ copilot_acp = CopilotACPProfile(
process_args_env_var="HERMES_COPILOT_ACP_ARGS",
# A delegation.command no other profile claims has always run through this ACP client.
process_command_fallback=True,
**SETUP_FIELDS,
**SETUP_FIELDS, **AUTH_ROW_FIELDS,
)
register_provider(copilot_acp)
@@ -0,0 +1,10 @@
"""The ``hermes auth`` registry row this profile mirrors into ``hermes_cli.auth.PROVIDER_REGISTRY``:
right after the ``copilot`` row, with its ``acp://`` base URL overridable from the environment."""
from typing import Any
AUTH_ROW_FIELDS: dict[str, Any] = {
"display_name": "GitHub Copilot ACP",
"base_url_env_var": "COPILOT_ACP_BASE_URL",
"registry_after": "copilot",
}
+2 -1
View File
@@ -10,6 +10,7 @@ from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
from .auth_rows import AUTH_ROW_FIELDS
from .catalog import HOSTNAMES, METADATA_HOSTNAMES, MODELS_DEV_ID, CopilotCatalogMixin
from .credentials import CopilotCredentialsMixin
from .responses import RESPONSES_PROFILE_FIELDS, CopilotResponsesMixin
@@ -45,7 +46,7 @@ copilot = CopilotProfile(
name="copilot", aliases=("github-copilot", "github-models", "github-model", "github"),
env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), base_url="https://api.githubcopilot.com",
auth_type="copilot", ambient_credentials=True, **RESPONSES_PROFILE_FIELDS, **SETUP_FIELDS,
hostnames=HOSTNAMES, metadata_hostnames=METADATA_HOSTNAMES, models_dev_id=MODELS_DEV_ID,
hostnames=HOSTNAMES, metadata_hostnames=METADATA_HOSTNAMES, models_dev_id=MODELS_DEV_ID, **AUTH_ROW_FIELDS,
)
register_provider(copilot)
@@ -0,0 +1,14 @@
"""The ``hermes auth`` registry row this profile mirrors into ``hermes_cli.auth.PROVIDER_REGISTRY``.
Credentials resolve like an api-key provider (env tokens, pool, ``hermes auth add copilot`` paste), while
``auth_type="copilot"`` names its own setup flow; the row keeps its historical place right after LM Studio.
"""
from typing import Any
AUTH_ROW_FIELDS: dict[str, Any] = {
"display_name": "GitHub Copilot",
"registry_auth_type": "api_key",
"base_url_env_var": "COPILOT_API_BASE_URL",
"registry_after": "lmstudio",
}
+5 -3
View File
@@ -31,9 +31,11 @@ consumer calls `get_provider_profile()` or `list_providers()`. See
The registry is populated on first access. After that, every downstream
layer reads from it:
- `hermes_cli/auth.py` extends `PROVIDER_REGISTRY` with every api-key
profile it sees (skipping `copilot`, `kimi-coding`, `kimi-coding-cn`,
`zai`, `openrouter`, `custom` — those need bespoke token resolution).
- `hermes_cli/auth_plugin_providers.py` mirrors every profile it sees into
`PROVIDER_REGISTRY` under its `registry_auth_type` (default `auth_type`),
placed after `registry_after` when set (skipping `kimi-coding`,
`kimi-coding-cn`, `zai`, `openrouter`, `custom` — those need bespoke token
resolution).
- `hermes_cli/models.py` extends `CANONICAL_PROVIDERS` and calls
`profile.fetch_models()` inside `provider_model_ids()`.
- `hermes_cli/doctor.py` adds a `/models` health check for each
+10
View File
@@ -688,3 +688,13 @@ class ProviderProfile:
def replays_function_thought_signature(self, *, model: str | None, base_url: str) -> bool:
"""True when replayed tool calls keep ``function.thought_signature`` (stripped everywhere else)."""
return False
# --- I2-auth: hermes-auth registry row mirrored from this profile (hermes_cli/auth_plugin_providers.py) ---
# auth_type of the registry row when credential resolution differs from the setup flow ``auth_type`` names
# (an env-token provider with its own login); "" = ``auth_type``.
registry_auth_type: str = ""
# Env var overriding ``base_url`` on the registry row when it is not listed in ``env_vars``; "" = none.
base_url_env_var: str = ""
# Registry id this provider's row sits right after, keeping its place in priority-ordered scans
# (aux api-key chain, env auto-detect); "" or an unknown id = appended.
registry_after: str = ""
@@ -0,0 +1,97 @@
"""A plugin profile's mirrored ``hermes auth`` registry row: shape, place, and env auto-detect.
A provider that leaves core keeps its registry row by declaring it on its profile
(``registry_auth_type``, ``base_url_env_var``, ``registry_after``), and the alias keys the mirror
adds for it never count as separate providers.
"""
from types import SimpleNamespace
import pytest
from providers.base import ProviderProfile
@pytest.fixture
def plugin(monkeypatch):
"""Register a profile the way discovery does (providers layer + auth mirror) on registry copies."""
import providers
from hermes_cli import auth
monkeypatch.setattr(providers, "_REGISTRY", dict(providers._REGISTRY))
monkeypatch.setattr(providers, "_ALIASES", dict(providers._ALIASES))
monkeypatch.setattr(providers, "_PROVIDER_LIST_CACHE", None, raising=False)
monkeypatch.setattr("hermes_cli.auth_plugin_providers.PLUGIN_MIRRORED_PROVIDERS", set())
# Restored in place: modules hold the dict itself (``from hermes_cli.auth import PROVIDER_REGISTRY``).
saved = dict(auth.PROVIDER_REGISTRY)
def _register(profile: ProviderProfile) -> ProviderProfile:
from hermes_cli.auth_plugin_providers import register_plugin_provider
providers.register_provider(profile)
register_plugin_provider(profile)
return profile
yield _register
auth.PROVIDER_REGISTRY.clear()
auth.PROVIDER_REGISTRY.update(saved)
def _token_profile(name: str, *, ambient: bool) -> ProviderProfile:
return ProviderProfile(
name=name, aliases=(f"{name}-alias", f"{name}-other"), env_vars=(f"{name.upper()}_TOKEN",),
base_url=f"https://{name}.example/v1", auth_type=f"{name}_flow", registry_auth_type="api_key",
ambient_credentials=ambient)
def test_ambient_plugin_is_not_env_auto_detected_under_its_aliases(plugin, monkeypatch):
"""An ambient token never auto-selects the provider, not even through the alias keys the mirror
points at its row; a non-ambient twin of the same shape is detected under its canonical id."""
from hermes_cli import auth
plugin(_token_profile("ambientco", ambient=True))
plugin(_token_profile("keyedco", ambient=False))
env = {"AMBIENTCO_TOKEN": "tok-ambient-1234567890"}
assert auth.PROVIDER_REGISTRY["ambientco-alias"].id == "ambientco"
assert auth._env_key_auto_detected(lambda n: env.get(n, ""), None) is None
env["KEYEDCO_TOKEN"] = "tok-keyed-1234567890"
assert auth._env_key_auto_detected(lambda n: env.get(n, ""), None) == "keyedco"
def test_profile_declares_its_registry_row_shape_and_place(plugin, monkeypatch, capsys):
"""registry_auth_type / base_url_env_var / registry_after produce the row a built-in would have
had, and `hermes auth add` takes the api-key path instead of demanding an auth_handler."""
from hermes_cli import auth
from hermes_cli.auth_commands import auth_add_command
plugin(ProviderProfile(
name="acme", display_name="Acme Cloud", aliases=("acme-alias",), env_vars=("ACME_TOKEN", "ACME_KEY"),
base_url="https://acme.example/v1", auth_type="acme_flow", registry_auth_type="api_key",
base_url_env_var="ACME_ENDPOINT", registry_after="lmstudio"))
row = auth.PROVIDER_REGISTRY["acme"]
assert (row.id, row.name, row.auth_type, row.inference_base_url) == (
"acme", "Acme Cloud", "api_key", "https://acme.example/v1")
assert (row.api_key_env_vars, row.base_url_env_var) == (("ACME_TOKEN", "ACME_KEY"), "ACME_ENDPOINT")
ids = list(auth.PROVIDER_REGISTRY)
assert ids[ids.index("lmstudio") + 1] == "acme"
auth_add_command(SimpleNamespace(provider="acme-alias", api_key="sk-acme-1234567890", label="work",
auth_type="", priority=None))
assert 'Added acme credential #1: "work"' in capsys.readouterr().out
def test_auth_list_shows_a_plugin_provider_once_not_per_alias(plugin, monkeypatch, capsys):
from hermes_cli.auth_commands import auth_list_command
from agent import secret_scope
plugin(_token_profile("listco", ambient=False))
token = secret_scope.set_secret_scope({"LISTCO_TOKEN": "tok-list-1234567890"})
try:
auth_list_command(SimpleNamespace(provider=""))
finally:
secret_scope.reset_secret_scope(token)
headers = [line for line in capsys.readouterr().out.splitlines() if line.startswith("listco")]
assert headers == ["listco (1 credentials):"]