From 4a18279c6255b2ebf802e8aa57b48b38f299e26c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:32:48 -0700 Subject: [PATCH] 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. --- hermes_cli/auth.py | 22 ++--- hermes_cli/auth_commands.py | 10 +- hermes_cli/auth_constants.py | 2 - hermes_cli/auth_plugin_providers.py | 44 +++++++-- hermes_cli/credential_lifecycle.py | 5 +- .../model-providers/copilot-acp/__init__.py | 3 +- .../model-providers/copilot-acp/auth_rows.py | 10 ++ plugins/model-providers/copilot/__init__.py | 3 +- plugins/model-providers/copilot/auth_rows.py | 14 +++ providers/README.md | 8 +- providers/base.py | 10 ++ .../test_auth_registry_profile_rows.py | 97 +++++++++++++++++++ 12 files changed, 191 insertions(+), 37 deletions(-) create mode 100644 plugins/model-providers/copilot-acp/auth_rows.py create mode 100644 plugins/model-providers/copilot/auth_rows.py create mode 100644 tests/hermes_cli/test_auth_registry_profile_rows.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c5273aa357..1e09429db3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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//) 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..provider: copilot`` selects the provider for that task the same way a MoA + # ``auxiliary..provider: `` 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 diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index c54be834a0..bf840bb290 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -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: diff --git a/hermes_cli/auth_constants.py b/hermes_cli/auth_constants.py index 4843fb1196..4694ee2264 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -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" diff --git a/hermes_cli/auth_plugin_providers.py b/hermes_cli/auth_plugin_providers.py index 09b775ed8b..8119d4465a 100644 --- a/hermes_cli/auth_plugin_providers.py +++ b/hermes_cli/auth_plugin_providers.py @@ -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 " diff --git a/hermes_cli/credential_lifecycle.py b/hermes_cli/credential_lifecycle.py index 0e88844b99..cce4d551f8 100644 --- a/hermes_cli/credential_lifecycle.py +++ b/hermes_cli/credential_lifecycle.py @@ -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) diff --git a/plugins/model-providers/copilot-acp/__init__.py b/plugins/model-providers/copilot-acp/__init__.py index 26c8497166..8c8a2ce7d7 100644 --- a/plugins/model-providers/copilot-acp/__init__.py +++ b/plugins/model-providers/copilot-acp/__init__.py @@ -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) diff --git a/plugins/model-providers/copilot-acp/auth_rows.py b/plugins/model-providers/copilot-acp/auth_rows.py new file mode 100644 index 0000000000..5b87e66c9e --- /dev/null +++ b/plugins/model-providers/copilot-acp/auth_rows.py @@ -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", +} diff --git a/plugins/model-providers/copilot/__init__.py b/plugins/model-providers/copilot/__init__.py index deedcee0a7..3070030217 100644 --- a/plugins/model-providers/copilot/__init__.py +++ b/plugins/model-providers/copilot/__init__.py @@ -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) diff --git a/plugins/model-providers/copilot/auth_rows.py b/plugins/model-providers/copilot/auth_rows.py new file mode 100644 index 0000000000..ed02dad463 --- /dev/null +++ b/plugins/model-providers/copilot/auth_rows.py @@ -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", +} diff --git a/providers/README.md b/providers/README.md index d012d3d66d..6d4bbac183 100644 --- a/providers/README.md +++ b/providers/README.md @@ -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 diff --git a/providers/base.py b/providers/base.py index da1f3bca24..7c79d19319 100644 --- a/providers/base.py +++ b/providers/base.py @@ -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 = "" diff --git a/tests/hermes_cli/test_auth_registry_profile_rows.py b/tests/hermes_cli/test_auth_registry_profile_rows.py new file mode 100644 index 0000000000..e2a4ff7ca7 --- /dev/null +++ b/tests/hermes_cli/test_auth_registry_profile_rows.py @@ -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):"]