fix(auth): a user provider plugin's endpoint overrides the bundled row too

A `$HERMES_HOME/plugins/model-providers/<name>/` plugin re-registering a
bundled provider (stepfun with a regional base_url, gmi at a staging host)
wins in `providers._REGISTRY` — register_provider() is last-writer-wins and
the plugin guide promises exactly this — but the runtime reads its endpoint
from `hermes_cli.auth.PROVIDER_REGISTRY`, whose mirror loop skipped every
name already present, so inference kept going to the built-in URL (#48450).

The mirror now applies one explicit precedence rule: when a row core wrote
(built-in or plugin-mirrored) belongs to a name whose profile
currently registered came from a USER plugin,
the row's profile-derived fields are rewritten in place (inference_base_url;
api_key_env_vars / base_url_env_var on api-key rows when the profile declares
env_vars). `providers` records the discovery source per registration
(`provider_source()`), because a bundled profile must never rewrite a
built-in row: several bundled profiles omit the row's `*_BASE_URL` env var
and one differs in auth_type, so an unconditional "profile wins" would have
changed built-in behaviour. With no user plugin PROVIDER_REGISTRY is
byte-identical before/after (78 rows probed). copilot/kimi/zai keep their
bespoke resolution via the existing skip set.

Co-authored-by: xiaoxinova <xiaoxinova@users.noreply.github.com>
This commit is contained in:
teknium1
2026-09-19 22:58:51 -07:00
committed by Teknium
parent 6733b9219a
commit e5e7fbcd27
5 changed files with 130 additions and 9 deletions

View File

@@ -246,6 +246,8 @@ _REGISTRY_ROWS: Tuple[Any, ...] = (
PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
p.id: p for p in (r if isinstance(r, ProviderConfig) else _api_key_provider(*r) for r in _REGISTRY_ROWS)
}
# The rows above, before any plugin touches the dict (a user plugin may override these; #48450).
BUILTIN_PROVIDER_IDS = frozenset(PROVIDER_REGISTRY)
# ``hermes_cli.config`` discovers model-provider plugins while importing, and a plugin may read this
# module's registry during that discovery. Keep the import below ProviderConfig / PROVIDER_REGISTRY so

View File

@@ -35,6 +35,13 @@ PLUGIN_AUTH_ACTIONS = ("add", "status", "logout", "refresh")
PLUGIN_MIRRORED_PROVIDERS: set[str] = set()
def _api_key_env_fields(pp: Any) -> tuple[tuple, str]:
"""Split a profile's ``env_vars`` into (api-key vars, base-URL var); the URL var may be ""."""
is_url = lambda v: v.endswith("_BASE_URL") or v.endswith("_URL") # noqa: E731
return (tuple(v for v in pp.env_vars if not is_url(v)) or pp.env_vars,
next((v for v in pp.env_vars if is_url(v)), None) or "")
def register_plugin_provider(pp: Any) -> None:
"""Mirror one profile into ``PROVIDER_REGISTRY`` under the ``auth_type`` it declares.
@@ -48,11 +55,7 @@ def register_plugin_provider(pp: Any) -> None:
if pp.auth_type == "api_key":
if not pp.env_vars:
return
is_url = lambda v: v.endswith("_BASE_URL") or v.endswith("_URL") # noqa: E731
pconfig = _api_key_provider(
pp.name, pp.display_name or pp.name, pp.base_url,
tuple(v for v in pp.env_vars if not is_url(v)) or pp.env_vars,
next((v for v in pp.env_vars if is_url(v)), None) or "")
pconfig = _api_key_provider(pp.name, pp.display_name or pp.name, pp.base_url, *_api_key_env_fields(pp))
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
@@ -61,25 +64,46 @@ def register_plugin_provider(pp: Any) -> None:
PROVIDER_REGISTRY.setdefault(alias, pconfig)
def override_registry_row(pconfig: Any, pp: Any) -> None:
"""A ``$HERMES_HOME`` plugin re-registering a name that already has a row wins for the fields
it declares — ``base_url`` and, on api-key rows, ``env_vars`` (#48450). ``register_provider()``
is last-writer-wins for the profile; without this the runtime kept reading the built-in
endpoint. In place, so alias rows sharing the object follow; idempotent, so re-sync is free.
"""
if pp.base_url:
pconfig.inference_base_url = pp.base_url
if pp.auth_type == "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
def sync_plugin_provider_registry() -> int:
"""Mirror provider-plugin profiles into ``PROVIDER_REGISTRY``; return how many were added.
Idempotent (existing entries are never replaced), so it is safe from resolution paths. It runs at
Idempotent (existing entries are never replaced — a user plugin re-registering a bundled name
only rewrites the fields it declares, see :func:`override_registry_row`), so it is safe from
resolution paths. It runs at
auth import and again whenever a name is missing (:func:`registry_lookup`) or when ``providers``
finishes discovery, because the import-time pass can observe a *partial* profile list: a plugin
whose own imports pull ``hermes_cli.auth`` in mid-``_discover_providers()`` sees only what was
registered so far, and every later plugin would otherwise fail with "Unknown provider" (#102123).
"""
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.auth import BUILTIN_PROVIDER_IDS, PROVIDER_REGISTRY
try:
from providers import list_providers
from providers import list_providers, provider_source
profiles = list_providers()
except Exception:
return 0
added = 0
for pp in profiles:
if pp.name in PROVIDER_REGISTRY:
# Only rows core wrote (built-in or mirrored) — a row the plugin injected itself is its
# own, more specific declaration and stays as written.
core_row = pp.name in BUILTIN_PROVIDER_IDS or pp.name in PLUGIN_MIRRORED_PROVIDERS
if core_row and pp.name not in _REGISTRY_PLUGIN_SKIP and provider_source(pp.name) == "user":
override_registry_row(PROVIDER_REGISTRY[pp.name], pp)
continue
register_plugin_provider(pp)
added += pp.name in PROVIDER_REGISTRY

View File

@@ -44,6 +44,10 @@ logger = logging.getLogger(__name__)
_REGISTRY: dict[str, ProviderProfile] = {}
_ALIASES: dict[str, str] = {}
# Where the CURRENT registration of each name came from: "bundled" / "user" (a
# ``$HERMES_HOME`` plugin dir) / "runtime" (entry point, legacy module, direct call).
_SOURCES: dict[str, str] = {}
_current_source: str | None = None
_PROVIDER_LIST_CACHE: list[ProviderProfile] | None = None
_discovered = False
_discovering = False
@@ -83,6 +87,7 @@ def register_provider(profile: ProviderProfile) -> None:
"""
global _PROVIDER_LIST_CACHE
_REGISTRY[profile.name] = profile
_SOURCES[profile.name] = _current_source or "runtime"
for alias in profile.aliases:
_ALIASES[alias] = profile.name
_PROVIDER_LIST_CACHE = None
@@ -90,6 +95,15 @@ def register_provider(profile: ProviderProfile) -> None:
_sync_auth_registry()
def provider_source(name: str) -> str | None:
"""Discovery source of the profile currently registered under *name* (see ``_SOURCES``), or None.
``"user"`` is what lets a ``$HERMES_HOME`` plugin re-registering a bundled name win in
``hermes_cli.auth.PROVIDER_REGISTRY`` too — a bundled profile never rewrites a built-in row.
"""
return _SOURCES.get(_ALIASES.get(name, name))
def get_provider_profile(name: str) -> ProviderProfile | None:
"""Look up a provider profile by name or alias.
@@ -217,8 +231,9 @@ def _declares_model_provider_kind(plugin_dir: Path) -> bool:
def _import_plugin_dir(plugin_dir: Path, source: str) -> None:
"""Import a single plugin directory so it self-registers.
``source`` is "bundled" or "user", used only for log messages.
``source`` is "bundled" or "user"; it is recorded per registered profile (``_SOURCES``).
"""
global _current_source
init_file = plugin_dir / "__init__.py"
if not init_file.exists():
return
@@ -236,6 +251,7 @@ def _import_plugin_dir(plugin_dir: Path, source: str) -> None:
if module_name in sys.modules:
return # already imported
_current_source = source
try:
spec = importlib.util.spec_from_file_location(
module_name, init_file, submodule_search_locations=[str(plugin_dir)]
@@ -250,6 +266,8 @@ def _import_plugin_dir(plugin_dir: Path, source: str) -> None:
"Failed to load %s provider plugin %s: %s", source, plugin_dir.name, exc
)
sys.modules.pop(module_name, None)
finally:
_current_source = None
def _discover_entry_point_providers() -> None:

View File

@@ -0,0 +1,67 @@
"""A `$HERMES_HOME` provider plugin re-registering a bundled name reaches the runtime (#48450).
``register_provider()`` is last-writer-wins for the profile, and the docs promise that dropping
``plugins/model-providers/<bundled-name>/`` points that provider at another endpoint. The runtime
reads ``hermes_cli.auth.PROVIDER_REGISTRY`` though, so the mirror has to carry the override across.
Each case runs in a fresh interpreter: real discovery, real auth import, no process-global leakage.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
_PROBE = """
import json, os
from providers import list_providers
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.runtime_provider import resolve_runtime_provider
list_providers()
row = PROVIDER_REGISTRY["stepfun"]
print(json.dumps({
"runtime_base_url": resolve_runtime_provider(requested="stepfun")["base_url"],
"api_key_env_vars": list(row.api_key_env_vars), "base_url_env_var": row.base_url_env_var,
"gmi_base_url": PROVIDER_REGISTRY["gmi"].inference_base_url}))
"""
def _run(tmp_path: Path, plugin_source: str) -> dict:
home = tmp_path / "home"
plugin_dir = home / "plugins" / "model-providers" / "stepfun"
plugin_dir.mkdir(parents=True)
(plugin_dir / "__init__.py").write_text(plugin_source, encoding="utf-8")
env = {**os.environ, "HERMES_HOME": str(home), "PYTHONPATH": str(REPO), "STEPFUN_API_KEY": "sk-fixture"}
env.pop("STEPFUN_BASE_URL", None)
proc = subprocess.run([sys.executable, "-c", _PROBE], env=env, capture_output=True, text=True, timeout=120,
cwd=str(REPO), check=False)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_user_plugin_endpoint_and_env_vars_reach_the_runtime(tmp_path):
result = _run(tmp_path, (
"from providers import register_provider\n"
"from providers.base import ProviderProfile\n"
"register_provider(ProviderProfile(name='stepfun', aliases=('step',), auth_type='api_key',\n"
" env_vars=('STEPFUN_API_KEY', 'STEPFUN_REGIONAL_BASE_URL'),\n"
" base_url='https://api.stepfun.com/step_plan/v1'))\n"))
assert result["runtime_base_url"] == "https://api.stepfun.com/step_plan/v1"
assert result["base_url_env_var"] == "STEPFUN_REGIONAL_BASE_URL"
assert result["api_key_env_vars"] == ["STEPFUN_API_KEY"]
def test_user_plugin_declaring_no_endpoint_keeps_the_builtin_row(tmp_path):
from hermes_cli.auth import PROVIDER_REGISTRY
result = _run(tmp_path, (
"from providers import register_provider\n"
"from providers.base import ProviderProfile\n"
"register_provider(ProviderProfile(name='stepfun', auth_type='api_key', env_vars=('STEPFUN_API_KEY',)))\n"))
assert result["runtime_base_url"] == PROVIDER_REGISTRY["stepfun"].inference_base_url
assert result["base_url_env_var"] == "STEPFUN_BASE_URL"
assert result["gmi_base_url"] == PROVIDER_REGISTRY["gmi"].inference_base_url

View File

@@ -304,6 +304,16 @@ register_provider(ProviderProfile(
In a fresh Hermes process, `get_provider_profile("gmi").base_url` returns the staging URL. No repo patch, no rebuild. Because user plugins are discovered after bundled ones, the user `register_provider()` call wins.
The override also reaches the runtime. Built-in providers have a row in `hermes_cli.auth.PROVIDER_REGISTRY` (the table `resolve_runtime_provider()` reads its endpoint and env vars from); a `$HERMES_HOME` plugin re-registering that name rewrites the row's profile-derived fields, so inference goes to the staging URL, not the bundled one:
| Profile field | Registry row field | When |
|---|---|---|
| `base_url` | `inference_base_url` | profile sets a non-empty `base_url` |
| `env_vars` (non-URL entries) | `api_key_env_vars` | api-key row and profile sets `env_vars` |
| `env_vars` (final `*_BASE_URL` / `*_URL` entry) | `base_url_env_var` | profile declares one; otherwise the built-in env var (e.g. `GMI_BASE_URL`) stays |
Only a **user** plugin (`$HERMES_HOME/plugins/model-providers/` or an installed `kind: model-provider` plugin) triggers this; a bundled profile never rewrites a built-in row, and `copilot`, `kimi-coding`, `kimi-coding-cn` and `zai` keep their bespoke credential resolution. A field the profile leaves empty keeps the built-in value. A `*_BASE_URL` env var still wins over both.
## api_mode selection
Four values are recognized. Hermes picks one based on: