diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 00925f5e2c..5b670b57b7 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1773,6 +1773,13 @@ display: # Aliases are checked BEFORE the models.dev catalog, so they can route # to endpoints not in the catalog (e.g. Ollama Cloud, local servers). # +# An alias pointing at its own endpoint can carry that endpoint's +# credential with `api_key` (a literal, or a "${VAR}" reference) or +# `key_env` (an env var name). `api_key` wins if both are set. When +# neither is set the key is resolved from the alias HOST — never from +# whatever provider was active before the switch, which would send that +# provider's secret to an unrelated third party. +# # model_aliases: # opus: # model: claude-opus-4-6 @@ -1785,6 +1792,11 @@ display: # model: glm-4.7 # provider: custom # base_url: "https://ollama.com/v1" +# theta: +# model: theta-1 +# provider: custom +# base_url: "https://theta.example.com/v1" +# key_env: THETA_API_KEY # or: api_key: "${THETA_API_KEY}" # ============================================================================= # Privacy diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 2760965ba3..6a13ce7b36 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -48,7 +48,7 @@ from agent.models_dev import ( get_model_info, list_provider_models, ) -from utils import base_url_host_matches, base_url_hostname +from utils import base_url_host_matches, base_url_hostname, base_url_origin # Providers whose picker model list should NOT be capped by max_models. # OpenCode Zen / Go are aggregators whose full catalogs (70+ models each) must @@ -513,10 +513,21 @@ MODEL_ALIASES: dict[str, ModelIdentity] = { # --------------------------------------------------------------------------- class DirectAlias(NamedTuple): - """Exact model mapping that bypasses catalog resolution.""" + """Exact model mapping that bypasses catalog resolution. + + ``api_key`` / ``key_env`` carry the alias endpoint's OWN credential. + Without them the switch keeps whatever key the *default* provider + resolved, which 401s against the alias host and sends that provider's + secret to an unrelated third party (#83612). + """ model: str provider: str base_url: str + # Defaulted so existing positional construction — + # ``DirectAlias(model, provider, base_url)`` — keeps working for callers + # and for the string-format aliases built below. + api_key: str = "" + key_env: str = "" # Built-in direct aliases (can be extended via config.yaml model_aliases:) @@ -540,6 +551,16 @@ def _load_direct_aliases() -> dict[str, DirectAlias]: model: "minimax-m2.7" provider: custom base_url: "https://ollama.com/v1" + theta: + model: "theta-1" + provider: custom + base_url: "https://theta.example.com/v1" + api_key: "sk-..." # literal, or "${THETA_API_KEY}" + key_env: "THETA_API_KEY" # read from the environment instead + + ``api_key``/``key_env`` are the alias endpoint's own credential. When + neither is set the key is resolved from the alias HOST, never from the + previously active provider (#83612). Also reads ``model.aliases`` (set by ``hermes config set model.aliases.xxx``) and converts simple string entries (``ds-flash: deepseek/deepseek-v4-flash``) @@ -563,6 +584,8 @@ def _load_direct_aliases() -> dict[str, DirectAlias]: if model: merged[name.strip().lower()] = DirectAlias( model=model, provider=provider, base_url=base_url, + api_key=str(entry.get("api_key", "") or "").strip(), + key_env=str(entry.get("key_env", "") or "").strip(), ) # --- model.aliases (string-based format, from config set) --- @@ -593,16 +616,132 @@ def _load_direct_aliases() -> dict[str, DirectAlias]: return merged +# Identity of the config the cached aliases were built from. The cache is +# process-global but its source is profile-local, so it must be keyed or the +# first profile to resolve an alias pins its definitions — and, since entries +# carry `api_key`, its credentials — for every later profile in the process. +# Same shape `load_config()` already keys its own cache on, so a profile +# switch (HERMES_HOME moves, so the path moves) and a config/key rotation +# (mtime/size move) both invalidate. +_DIRECT_ALIAS_IDENTITY: Optional[tuple] = None +# A copy of what this loader last produced. Callers and tests seed +# DIRECT_ALIASES both by rebinding the module attribute AND by editing it in +# place, so neither the object's identity nor a "did we load" flag can tell +# our own stale cache from someone else's contents. Comparing against what we +# actually wrote does: if the dict no longer holds it, the entries are not +# ours to discard. +_DIRECT_ALIAS_LOADED: Optional[dict] = None + + +def _direct_alias_source_identity() -> Optional[tuple]: + """Identity of the active profile's alias source, or None if unknowable. + + None means "do not reuse the cache" — a source we cannot identify must + not be assumed to be the one already loaded. + """ + try: + from hermes_constants import get_config_path + + path = get_config_path() + try: + stat = path.stat() + except OSError: + # A missing config is still a definite identity for this profile. + return (str(path), None, None) + return (str(path), stat.st_mtime_ns, stat.st_size) + except Exception: + return None + + def _ensure_direct_aliases() -> None: - """Lazy-load direct aliases on first use. + """Load direct aliases for the ACTIVE profile, caching per config identity. Mutates the existing DIRECT_ALIASES dict in place rather than rebinding the module attribute. This keeps `from hermes_cli.model_switch import DIRECT_ALIASES` references valid in callers — rebinding would leave them pointing at a stale empty dict. """ - if not DIRECT_ALIASES: - DIRECT_ALIASES.update(_load_direct_aliases()) + global _DIRECT_ALIAS_IDENTITY, _DIRECT_ALIAS_LOADED + identity = _direct_alias_source_identity() + if DIRECT_ALIASES and ( + # Contents are not what we loaded — seeded or edited by a caller. + # Not ours to discard. + DIRECT_ALIASES != _DIRECT_ALIAS_LOADED + # Ours, and still the same config file at the same signature. + or (identity is not None and identity == _DIRECT_ALIAS_IDENTITY) + ): + return + loaded = _load_direct_aliases() + # clear()+update() rather than a rebind: callers hold this exact dict. + DIRECT_ALIASES.clear() + DIRECT_ALIASES.update(loaded) + _DIRECT_ALIAS_IDENTITY = identity + _DIRECT_ALIAS_LOADED = dict(loaded) + + +def direct_alias_api_key(alias: DirectAlias) -> str: + """Resolve a direct alias's own credential, or "" when it has none. + + Precedence, highest first — ``api_key`` always wins over ``key_env``, so + an entry carrying both is not ambiguous: + + 1. ``api_key: "${VAR}"`` — indirection, read from the environment. + 2. ``api_key: "sk-..."`` — literal. + 3. ``key_env: VAR`` — read from the environment. + 4. otherwise "" — the caller resolves from the alias host instead. + Environment reads go through the per-profile secret scope for the same + reason the user-provider branch does: a raw ``os.environ`` read hands + this profile whatever key the process env holds — another profile's, + under the multiplexed gateway. + """ + raw = (alias.api_key or "").strip() + if raw.startswith("${") and raw.endswith("}"): + return _scoped_key_env(raw[2:-1].strip()) + if raw: + return raw + return _scoped_key_env((alias.key_env or "").strip()) + + +def direct_alias_runtime_request(alias: DirectAlias) -> tuple[str, Optional[str]]: + """Return ``(requested_provider, explicit_api_key)`` for resolving *alias*. + + Single owner of the invariant that a URL-bearing direct alias resolves its + credential for the alias HOST, never for its provider label. A label like + ``anthropic`` on an unrelated URL would otherwise reach that provider's + explicit-runtime branch, keep the foreign URL, and fall back to the live + vendor token. Bare ``custom`` is host-gated (#28660), so an authoritative + URL still resolves its vendor key and a foreign one resolves none. + + An alias with no base_url keeps its label: there is no foreign host to + protect against, and the label is the only routing information there is. + """ + key = direct_alias_api_key(alias) or None + if alias.base_url: + return "custom", key + return (alias.provider or "custom"), key + + +# Hosts where plaintext HTTP is not a downgrade — a local server has no +# network hop to intercept. +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"}) + + +def _may_reuse_session_credential(session_base_url: str, alias_base_url: str) -> bool: + """Whether the session's key may follow a switch to *alias_base_url*. + + Same hostname is NOT sufficient to authorise handing a bearer secret to a + new URL. ``http://h`` and ``https://h:8443`` are different origins and + different trust boundaries, so an alias that keeps the hostname but drops + the scheme would otherwise put a live session credential on the wire in + the clear. Require an identical (scheme, host, port), and refuse plaintext + outside loopback. + """ + session = base_url_origin(session_base_url) + alias = base_url_origin(alias_base_url) + if not session[1] or session != alias: + return False + scheme, hostname, _ = alias + return scheme == "https" or hostname in _LOOPBACK_HOSTS # --------------------------------------------------------------------------- @@ -1985,9 +2124,70 @@ def switch_model( _ensure_direct_aliases() _da = DIRECT_ALIASES.get(resolved_alias) if _da is not None and _da.base_url: - base_url = _da.base_url + # Credentials above were resolved against the DEFAULT provider. + # Carrying that key onto the alias's endpoint both 401s and ships + # the default provider's secret to an unrelated third-party host + # (#83612). The alias's own endpoint decides the credential + # instead: its declared key when it has one, otherwise a fresh + # resolution against the alias base_url, whose env-key fallbacks + # are gated on authoritative hosts (#28660) — so OLLAMA_API_KEY + # still resolves for an ollama.com alias while OPENROUTER_API_KEY + # never reaches an unrelated host. + _alias_key = direct_alias_api_key(_da) + if _alias_key: + # The alias states its own credential: nothing left to + # resolve, and re-entering the resolver would only risk a + # second local-endpoint model probe. + base_url = _da.base_url + api_key = _alias_key + elif api_key and api_key != "no-key-required" and ( + _may_reuse_session_credential(base_url, _da.base_url) + ): + # The alias points at the very origin the resolution above + # already produced a key for, so that key is the + # host-appropriate one and re-entering the resolver would only + # repeat the work — including, for a local endpoint with no + # configured model, a second bounded /models probe. + base_url = _da.base_url + else: + try: + # Shared owner of the label-vs-host invariant; the one-shot + # path resolves through the same helper. + _req, _explicit = direct_alias_runtime_request(_da) + _alias_runtime = resolve_runtime_provider( + requested=_req, + explicit_api_key=_explicit, + explicit_base_url=_da.base_url, + target_model=new_model, + ) + except Exception: + _alias_runtime = {} + # The already-resolved key is reusable only when the alias + # points at the SAME ORIGIN it was resolved for (an alias that + # just pins a model on the endpoint already in use). Across + # origins it is the leak, so it is dropped, not carried. + _same_host = _may_reuse_session_credential(base_url, _da.base_url) + base_url = _alias_runtime.get("base_url", "") or _da.base_url + # The resolver reports "no key found" with the + # `no-key-required` placeholder rather than "". Normalise it + # so a same-host credential still outranks the placeholder. + _resolved_key = _alias_runtime.get("api_key", "") + if _resolved_key == "no-key-required": + _resolved_key = "" + api_key = ( + _resolved_key + or (api_key if _same_host else "") + or "no-key-required" + ) api_mode = "" # clear so determine_api_mode re-detects from URL - if target_provider.strip().lower() == "ollama": + # Upstream's providers.ollama refinement: pick up the + # configured key only for the configured native root, and drop + # both the key and the provider-level headers for any other + # origin. Orthogonal to the resolution above and kept as-is — + # except that it is skipped when the alias declared its own + # credential, since an explicit api_key/key_env outranks a + # provider-level config key (this PR's documented precedence). + if not _alias_key and target_provider.strip().lower() == "ollama": _ollama_cfg = _get_provider_config_dict("ollama") _ollama_cfg_base = str( _ollama_cfg.get("base_url") diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index e22a8ecb5a..e2778d67d7 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -399,6 +399,7 @@ def _run_agent( # the caller just asked for. effective_provider = (provider or "").strip() or None explicit_base_url_from_alias: Optional[str] = None + explicit_api_key_from_alias: Optional[str] = None if effective_provider is None and (model or env_model): # Only auto-detect when the model was explicitly requested via arg or # env var (not when it came from config — that's the "use my defaults" @@ -417,6 +418,20 @@ def _run_agent( if direct is not None: effective_model = direct.model effective_provider = direct.provider + # Resolve the alias through the SAME owner the interactive + # `/model` path uses. Passing `direct.provider` alongside a + # URL-bearing alias would let a label like `anthropic` reach + # that provider's explicit-runtime branch, keep the alias's + # unrelated base_url, and fall back to the live vendor token — + # a bearer credential crossing an origin boundary. The helper + # forces bare `custom` for URL-bearing aliases (host-gated, + # #28660) and carries the alias's own key when it declares one. + try: + effective_provider, explicit_api_key_from_alias = ( + _ms.direct_alias_runtime_request(direct) + ) + except Exception: + explicit_api_key_from_alias = None if direct.base_url: explicit_base_url_from_alias = direct.base_url.rstrip("/") else: @@ -436,6 +451,7 @@ def _run_agent( requested=effective_provider, target_model=effective_model or None, explicit_base_url=explicit_base_url_from_alias, + explicit_api_key=explicit_api_key_from_alias, ) # Pull in explicit toolsets when provided; otherwise use whatever the user diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 03bb777e90..b663a92ca5 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1226,9 +1226,15 @@ def _resolve_named_custom_runtime( return pool_result _da_is_openai_url = base_url_host_matches(base_url, "openai.com") or base_url_host_matches(base_url, "openai.azure.com") _da_is_openrouter = base_url_host_matches(base_url, "openrouter.ai") + _da_is_ollama_url = base_url_host_matches(base_url, "ollama.com") api_key_candidates = [ (explicit_api_key or "").strip(), # Gate env key fallbacks on authoritative hosts (#28660) + # OLLAMA_API_KEY needs its own gate here: _host_derived_api_key + # deliberately skips it, expecting an explicit host-matched path + # like this one (GHSA-76xc-57q6-vm5m). Without it a `model_aliases:` + # entry pointing at Ollama Cloud resolved no key at all. + (_getenv("OLLAMA_API_KEY", "").strip() if _da_is_ollama_url else ""), (_getenv("OPENAI_API_KEY", "").strip() if _da_is_openai_url else ""), (_getenv("OPENROUTER_API_KEY", "").strip() if _da_is_openrouter else ""), # Bonus (#28660): derive `_API_KEY` from the host so users diff --git a/skills/autonomous-ai-agents/hermes-agent/references/providers-and-models.md b/skills/autonomous-ai-agents/hermes-agent/references/providers-and-models.md index 4c9cc9ea13..71d46afe52 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/providers-and-models.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/providers-and-models.md @@ -48,6 +48,11 @@ model_aliases: model: qwen3.5:397b provider: custom base_url: "https://ollama.com/v1" + theta: + model: theta-1 + provider: custom + base_url: "https://theta.example.com/v1" + key_env: THETA_API_KEY # or: api_key: "${THETA_API_KEY}" # Short form ("provider/model"), also via CLI: # hermes config set model.aliases.fav openrouter/anthropic/claude-sonnet-4.6 @@ -58,6 +63,11 @@ model: `/model fav` — session-scoped; add `--global` to persist as default. +An alias with its own `base_url` authenticates with its own credential +(`api_key`, which also accepts a `"${VAR}"` reference, or `key_env`). With +neither set the key is resolved from the alias HOST, never carried over from +the provider that was active before the switch. + Built-in aliases (catalog-resolved against the active provider): `sonnet`, `opus`, `haiku`, `claude`, `gpt5`, `gpt`, `codex`, `o3`, `o4`, `gemini`, `deepseek`, `grok`, `llama`, `qwen`, `minimax`, `nemotron`, `kimi`, `glm`, diff --git a/tests/hermes_cli/test_model_alias_credentials_83612.py b/tests/hermes_cli/test_model_alias_credentials_83612.py new file mode 100644 index 0000000000..8b23b93b87 --- /dev/null +++ b/tests/hermes_cli/test_model_alias_credentials_83612.py @@ -0,0 +1,850 @@ +"""Direct-alias (``model_aliases:``) credential resolution (#83612). + +An alias that points at a custom endpoint must authenticate with **its own** +credential. Before the fix ``DirectAlias`` had no ``api_key`` field at all, so +a configured key was silently dropped and the alias inherited whatever key the +*default* provider had already resolved — a 401 against the alias host and a +cross-provider credential leak to an unrelated third party. + +The regression that matters most is the leak: assert on the credential the +endpoint probe is actually handed, not just on the returned struct. +""" + +import pytest + + +ALIAS_HOST = "https://theta.example.com/v1" +DEFAULT_PROVIDER_SECRET = "sk-or-DEFAULT-PROVIDER-SECRET" + + +def _install_config(monkeypatch, alias_entry): + """Point every config reader at a single-alias config.""" + cfg = { + "model": {"default": "gpt-4", "provider": "openrouter"}, + "model_aliases": {"theta": alias_entry}, + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: cfg) + monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda *a, **k: cfg) + return cfg + + +def _switch_to_alias(monkeypatch, alias_entry): + """Run ``/model theta`` and capture what the endpoint probe was given. + + Returns ``(result, probed)`` where ``probed`` holds the api_key/base_url + handed to ``validate_requested_model`` — i.e. the credential that goes out + on the wire to the alias host. + """ + _install_config(monkeypatch, alias_entry) + monkeypatch.setenv("OPENROUTER_API_KEY", DEFAULT_PROVIDER_SECRET) + + probed = {} + + def _fake_validate(model_name, provider, *, api_key=None, base_url=None, + api_mode=None, **_kwargs): + probed["api_key"] = api_key + probed["base_url"] = base_url + return {"accepted": True, "persist": True, "recognized": True, "message": ""} + + monkeypatch.setattr( + "hermes_cli.models.validate_requested_model", _fake_validate + ) + + import hermes_cli.model_switch as ms + + monkeypatch.setattr(ms, "DIRECT_ALIASES", {}) + result = ms.switch_model( + raw_input="theta", + current_provider="openrouter", + current_model="gpt-4", + current_base_url="https://openrouter.ai/api/v1", + current_api_key=DEFAULT_PROVIDER_SECRET, + ) + return result, probed + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + +class TestDirectAliasCredentialLoading: + def test_api_key_and_key_env_are_loaded_from_config(self, monkeypatch): + """``api_key``/``key_env`` survive into the DirectAlias (were dropped).""" + _install_config( + monkeypatch, + { + "model": "theta-1", + "provider": "custom", + "base_url": ALIAS_HOST, + "api_key": "sk-literal", + "key_env": "THETA_API_KEY", + }, + ) + from hermes_cli.model_switch import _load_direct_aliases + + alias = _load_direct_aliases()["theta"] + assert alias.api_key == "sk-literal" + assert alias.key_env == "THETA_API_KEY" + + def test_credential_fields_default_to_empty(self, monkeypatch): + """Aliases without credentials keep working (positional construction).""" + from hermes_cli.model_switch import DirectAlias + + alias = DirectAlias("theta-1", "custom", ALIAS_HOST) + assert alias.api_key == "" + assert alias.key_env == "" + + +class TestDirectAliasApiKeyHelper: + @pytest.mark.parametrize( + "entry, expected", + [ + ({"api_key": "sk-literal"}, "sk-literal"), + ({"api_key": "${THETA_API_KEY}"}, "sk-from-env"), + ({"key_env": "THETA_API_KEY"}, "sk-from-env"), + ({}, ""), + ], + ) + def test_resolves_literal_env_template_and_key_env( + self, monkeypatch, entry, expected + ): + monkeypatch.setenv("THETA_API_KEY", "sk-from-env") + from hermes_cli.model_switch import DirectAlias, direct_alias_api_key + + alias = DirectAlias("theta-1", "custom", ALIAS_HOST, **entry) + assert direct_alias_api_key(alias) == expected + + +# --------------------------------------------------------------------------- +# /model — the switch path +# --------------------------------------------------------------------------- + +class TestModelSwitchUsesAliasCredential: + def test_alias_api_key_is_sent_to_alias_host(self, monkeypatch): + result, probed = _switch_to_alias( + monkeypatch, + { + "model": "theta-1", + "provider": "custom", + "base_url": ALIAS_HOST, + "api_key": "sk-theta-ALIAS-SECRET", + }, + ) + assert result.base_url == ALIAS_HOST + assert result.api_key == "sk-theta-ALIAS-SECRET" + assert probed["api_key"] == "sk-theta-ALIAS-SECRET" + + def test_alias_key_env_is_sent_to_alias_host(self, monkeypatch): + monkeypatch.setenv("THETA_API_KEY", "sk-theta-FROM-ENV") + result, _ = _switch_to_alias( + monkeypatch, + { + "model": "theta-1", + "provider": "custom", + "base_url": ALIAS_HOST, + "key_env": "THETA_API_KEY", + }, + ) + assert result.api_key == "sk-theta-FROM-ENV" + + def test_default_provider_key_never_reaches_the_alias_host(self, monkeypatch): + """The leak: an alias with no credential of its own must NOT inherit + the default provider's key just because that key was resolved first.""" + result, probed = _switch_to_alias( + monkeypatch, + {"model": "theta-1", "provider": "custom", "base_url": ALIAS_HOST}, + ) + assert result.base_url == ALIAS_HOST + assert result.api_key != DEFAULT_PROVIDER_SECRET + assert probed["api_key"] != DEFAULT_PROVIDER_SECRET + assert probed["base_url"] == ALIAS_HOST + + def test_same_host_alias_still_resolves_that_host_key(self, monkeypatch): + """Host-gated resolution keeps working: an openrouter.ai alias still + gets OPENROUTER_API_KEY — this is not a blanket "drop the key".""" + result, _ = _switch_to_alias( + monkeypatch, + { + "model": "theta-1", + "provider": "custom", + "base_url": "https://openrouter.ai/api/v1", + }, + ) + assert result.api_key == DEFAULT_PROVIDER_SECRET + + def test_ollama_cloud_alias_resolves_ollama_api_key(self, monkeypatch): + """Ollama Cloud aliases authenticate with OLLAMA_API_KEY, not the + previously active provider's key.""" + monkeypatch.setenv("OLLAMA_API_KEY", "sk-ollama-KEY") + result, _ = _switch_to_alias( + monkeypatch, + { + "model": "qwen3.5:397b", + "provider": "custom", + "base_url": "https://ollama.com/v1", + }, + ) + assert result.api_key == "sk-ollama-KEY" + + +class TestSessionKeyIsHostScoped: + """The key already resolved for the session is reusable only on the same + host. This is what keeps a user pinned to a custom endpoint working while + still closing the cross-host leak.""" + + def _switch(self, monkeypatch, session_base_url): + cfg = { + "model": {"default": "m", "provider": "ollama-launch"}, + "model_aliases": { + "theta": { + "model": "theta-1", + "provider": "custom", + "base_url": "https://myhost.test/v1", + } + }, + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: cfg) + monkeypatch.setattr( + "hermes_cli.runtime_provider.load_config", lambda *a, **k: cfg + ) + monkeypatch.setattr( + "hermes_cli.models.validate_requested_model", + lambda *a, **k: { + "accepted": True, + "persist": True, + "recognized": True, + "message": "", + }, + ) + import hermes_cli.model_switch as ms + + monkeypatch.setattr(ms, "DIRECT_ALIASES", {}) + return ms.switch_model( + raw_input="theta", + current_provider="ollama-launch", + current_model="m", + current_base_url=session_base_url, + current_api_key="sk-session-KEY", + ) + + def test_same_host_alias_keeps_the_session_key(self, monkeypatch): + result = self._switch(monkeypatch, "https://myhost.test/v1") + assert result.api_key == "sk-session-KEY" + + def test_different_host_alias_drops_the_session_key(self, monkeypatch): + result = self._switch(monkeypatch, "https://elsewhere.test/v1") + assert result.api_key != "sk-session-KEY" + + +class TestBuiltinProviderKeysDoNotLeak: + """The leak is not specific to custom providers. A session on a built-in + provider (Anthropic, OpenAI, ...) must not forward its key either when the + alias resolves to an unrelated host — the credential is dropped on host + mismatch regardless of which branch resolved it.""" + + @pytest.mark.parametrize( + "provider, env_var, session_base_url", + [ + ("anthropic", "ANTHROPIC_API_KEY", "https://api.anthropic.com"), + ("openai", "OPENAI_API_KEY", "https://api.openai.com/v1"), + ], + ) + def test_builtin_provider_key_not_forwarded_to_alias_host( + self, monkeypatch, provider, env_var, session_base_url + ): + secret = f"sk-{provider}-SECRET" + cfg = { + "model": {"default": "m", "provider": provider}, + "model_aliases": { + "theta": { + "model": "theta-1", + "provider": "custom", + "base_url": ALIAS_HOST, + } + }, + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: cfg) + monkeypatch.setattr( + "hermes_cli.runtime_provider.load_config", lambda *a, **k: cfg + ) + monkeypatch.setenv(env_var, secret) + + probed = {} + + def _fake_validate(model_name, prov, *, api_key=None, base_url=None, + api_mode=None, **_kwargs): + probed["api_key"] = api_key + probed["base_url"] = base_url + return {"accepted": True, "persist": True, "recognized": True, "message": ""} + + monkeypatch.setattr( + "hermes_cli.models.validate_requested_model", _fake_validate + ) + import hermes_cli.model_switch as ms + + monkeypatch.setattr(ms, "DIRECT_ALIASES", {}) + result = ms.switch_model( + raw_input="theta", + current_provider=provider, + current_model="m", + current_base_url=session_base_url, + current_api_key=secret, + ) + assert result.base_url == ALIAS_HOST + assert result.api_key != secret + assert probed["api_key"] != secret + + +class TestProviderLabelCannotSelectAKeyForAnArbitraryHost: + """A direct alias's `provider:` label must not route credential resolution. + + With a base_url but no declared credential, a label like `anthropic` used + to reach that provider's own resolver, which picks ANTHROPIC_API_KEY out + of the environment while keeping the alias's unrelated base_url — a + built-in provider's bearer secret handed to a third-party host. The alias + endpoint is resolved as bare `custom` instead, which is host-gated. + """ + + def _switch(self, monkeypatch, alias, session_provider="openrouter", + session_base_url="https://openrouter.ai/api/v1"): + cfg = { + "model": {"default": "m", "provider": session_provider}, + "model_aliases": {"theta": alias}, + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: cfg) + monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda *a, **k: cfg) + probed = {} + + def _fake_validate(model_name, prov, *, api_key=None, base_url=None, + api_mode=None, **_kwargs): + probed["api_key"] = api_key + return {"accepted": True, "persist": True, "recognized": True, "message": ""} + + monkeypatch.setattr("hermes_cli.models.validate_requested_model", _fake_validate) + import hermes_cli.model_switch as ms + + monkeypatch.setattr(ms, "DIRECT_ALIASES", {}) + result = ms.switch_model( + raw_input="theta", current_provider=session_provider, current_model="m", + current_base_url=session_base_url, current_api_key="sk-session", + ) + return result, probed + + @pytest.mark.parametrize("provider, env_var", [ + ("anthropic", "ANTHROPIC_API_KEY"), + ("openai", "OPENAI_API_KEY"), + ("openrouter", "OPENROUTER_API_KEY"), + ]) + def test_builtin_label_does_not_pull_that_providers_key_to_a_foreign_host( + self, monkeypatch, provider, env_var + ): + secret = f"sk-{provider}-SECRET" + monkeypatch.setenv(env_var, secret) + result, probed = self._switch( + monkeypatch, + {"model": "c", "provider": provider, "base_url": "https://evil.test/v1"}, + ) + # Either the switch resolves no key for the foreign host, or it fails + # outright — never the built-in provider's secret. + assert result.api_key != secret + assert probed.get("api_key") != secret + + def test_authoritative_host_still_resolves_its_vendor_key(self, monkeypatch): + """Host gating is the point, not a blanket refusal: an alias whose URL + IS authoritative for the vendor still authenticates.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-anthropic-SECRET") + result, _ = self._switch( + monkeypatch, + {"model": "c", "provider": "anthropic", + "base_url": "https://api.anthropic.com/v1"}, + ) + assert result.api_key == "sk-anthropic-SECRET" + + +class TestSessionCredentialIsScopedToTheOrigin: + """Reusing the session key across a scheme or port change is a downgrade. + + The override compared hostnames only, so an alias could keep the host and + move an HTTPS session to `http://` (or another port) while the live + credential followed it onto the new, untrusted origin. + """ + + def _switch(self, monkeypatch, alias_base_url, session_base_url): + cfg = { + "model": {"default": "m", "provider": "my-endpoint"}, + "model_aliases": {"theta": { + "model": "m2", "provider": "custom", "base_url": alias_base_url}}, + } + monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: cfg) + monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda *a, **k: cfg) + monkeypatch.setattr( + "hermes_cli.models.validate_requested_model", + lambda *a, **k: {"accepted": True, "persist": True, + "recognized": True, "message": ""}, + ) + import hermes_cli.model_switch as ms + + monkeypatch.setattr(ms, "DIRECT_ALIASES", {}) + return ms.switch_model( + raw_input="theta", current_provider="my-endpoint", current_model="m", + current_base_url=session_base_url, current_api_key="sk-SESSION-SECRET", + ) + + @pytest.mark.parametrize("alias_url, session_url, why", [ + ("http://api.example.com/v1", "https://api.example.com/v1", "https->http"), + ("https://api.example.com:8443/v1", "https://api.example.com/v1", "port change"), + ("http://api.example.com:8080/v1", "https://api.example.com/v1", "scheme+port"), + ("https://other.example.com/v1", "https://api.example.com/v1", "cross-host"), + ]) + def test_origin_change_drops_the_session_credential( + self, monkeypatch, alias_url, session_url, why + ): + assert self._switch(monkeypatch, alias_url, session_url).api_key != "sk-SESSION-SECRET" + + @pytest.mark.parametrize("url", [ + "https://api.example.com/v1", + "http://127.0.0.1:11434/v1", # loopback plaintext is not a downgrade + "http://localhost:8080/v1", + ]) + def test_same_origin_keeps_the_session_credential(self, monkeypatch, url): + assert self._switch(monkeypatch, url, url).api_key == "sk-SESSION-SECRET" + + def test_default_port_and_explicit_port_are_the_same_origin(self, monkeypatch): + result = self._switch( + monkeypatch, "https://api.example.com:443/v1", "https://api.example.com/v1" + ) + assert result.api_key == "sk-SESSION-SECRET" + + +class TestCredentialPrecedenceIsExplicit: + """`api_key` outranks `key_env`, so an entry carrying both is unambiguous.""" + + def test_api_key_wins_over_key_env(self, monkeypatch): + monkeypatch.setenv("THETA_API_KEY", "sk-from-key-env") + from hermes_cli.model_switch import DirectAlias, direct_alias_api_key + + alias = DirectAlias("theta-1", "custom", ALIAS_HOST, + "sk-literal", "THETA_API_KEY") + assert direct_alias_api_key(alias) == "sk-literal" + + def test_env_template_api_key_also_wins_over_key_env(self, monkeypatch): + monkeypatch.setenv("PRIMARY", "sk-from-template") + monkeypatch.setenv("FALLBACK", "sk-from-key-env") + from hermes_cli.model_switch import DirectAlias, direct_alias_api_key + + alias = DirectAlias("theta-1", "custom", ALIAS_HOST, + "${PRIMARY}", "FALLBACK") + assert direct_alias_api_key(alias) == "sk-from-template" + + def test_key_env_used_when_api_key_is_blank(self, monkeypatch): + monkeypatch.setenv("FALLBACK", "sk-from-key-env") + from hermes_cli.model_switch import DirectAlias, direct_alias_api_key + + alias = DirectAlias("theta-1", "custom", ALIAS_HOST, " ", "FALLBACK") + assert direct_alias_api_key(alias) == "sk-from-key-env" + + +class TestSchemelessBaseUrls: + """Scheme-less base URLs. + + A loopback alias written without a scheme keeps working — the loopback + exemption does not depend on the scheme. A scheme-less URL that also + changes origin is refused, which costs nothing in practice: httpx cannot + build a client from one at all (`localhost:11434/v1` parses as + scheme='localhost', host=''), so such a base_url is non-functional + regardless of what this comparison decides. + """ + + def test_hostname_and_port_survive_without_a_scheme(self): + from utils import base_url_origin + + assert base_url_origin("localhost:11434/v1") == ("", "localhost", 11434) + assert base_url_origin("127.0.0.1:11434") == ("", "127.0.0.1", 11434) + + @pytest.mark.parametrize("url", ["localhost:11434/v1", "127.0.0.1:11434/v1"]) + def test_schemeless_loopback_alias_keeps_the_session_credential(self, url): + from hermes_cli.model_switch import _may_reuse_session_credential + + assert _may_reuse_session_credential(url, url) is True + + def test_schemeless_is_not_treated_as_the_schemed_origin(self): + """`http://h` and `h` are not asserted equal — an unknown scheme is + not evidence that the origin is unchanged.""" + from hermes_cli.model_switch import _may_reuse_session_credential + + assert _may_reuse_session_credential( + "http://localhost:11434/v1", "localhost:11434/v1" + ) is False + + def test_httpx_cannot_use_a_schemeless_base_url(self): + """Pins the premise above: this is why the strict answer is harmless.""" + httpx = pytest.importorskip("httpx") + + assert httpx.URL("localhost:11434/v1").host == "" + assert httpx.URL("api.example.com/v1").host == "" + assert httpx.URL("http://localhost:11434/v1").host == "localhost" + + +class TestAliasCacheIsProfileScoped: + """DIRECT_ALIASES is process-global; its source is profile-local. + + Entries carry `api_key`, so an unkeyed cache lets the first profile to + resolve an alias pin its definitions AND its credentials for every later + profile in the process. These tests switch profiles inside one process + and never clear the cache by hand — clearing it would hide the bug. + """ + + def _profile(self, tmp_path, name, body): + home = tmp_path / name + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text(body, encoding="utf-8") + return home + + def _load(self, monkeypatch, home): + monkeypatch.setenv("HERMES_HOME", str(home)) + import hermes_cli.model_switch as ms + + ms._ensure_direct_aliases() + return ms.DIRECT_ALIASES + + def test_second_profile_does_not_inherit_the_first_profiles_key( + self, tmp_path, monkeypatch + ): + a = self._profile(tmp_path, "a", ( + 'model_aliases:\n' + ' theta:\n model: a-model\n provider: custom\n' + ' base_url: "https://a.example.com/v1"\n' + ' api_key: "sk-PROFILE-A-SECRET"\n' + )) + b = self._profile(tmp_path, "b", ( + 'model_aliases:\n' + ' theta:\n model: b-model\n provider: custom\n' + ' base_url: "https://b.example.com/v1"\n' + ' api_key: "sk-PROFILE-B-SECRET"\n' + )) + assert self._load(monkeypatch, a)["theta"].api_key == "sk-PROFILE-A-SECRET" + + theta = self._load(monkeypatch, b)["theta"] + assert theta.api_key == "sk-PROFILE-B-SECRET" + assert theta.model == "b-model" + assert theta.base_url == "https://b.example.com/v1" + + def test_alias_absent_from_the_second_profile_does_not_persist( + self, tmp_path, monkeypatch + ): + a = self._profile(tmp_path, "a2", ( + 'model_aliases:\n' + ' only-in-a:\n model: x\n provider: custom\n' + ' base_url: "https://a.example.com/v1"\n' + )) + b = self._profile(tmp_path, "b2", ( + 'model_aliases:\n' + ' only-in-b:\n model: y\n provider: custom\n' + ' base_url: "https://b.example.com/v1"\n' + )) + assert "only-in-a" in self._load(monkeypatch, a) + + loaded = self._load(monkeypatch, b) + assert "only-in-a" not in loaded + assert "only-in-b" in loaded + + def test_key_rotation_in_place_is_picked_up(self, tmp_path, monkeypatch): + home = self._profile(tmp_path, "rot", ( + 'model_aliases:\n' + ' theta:\n model: m\n provider: custom\n' + ' base_url: "https://h.example.com/v1"\n' + ' api_key: "sk-BEFORE-ROTATION"\n' + )) + assert self._load(monkeypatch, home)["theta"].api_key == "sk-BEFORE-ROTATION" + + (home / "config.yaml").write_text(( + 'model_aliases:\n' + ' theta:\n model: m\n provider: custom\n' + ' base_url: "https://h.example.com/v1"\n' + ' api_key: "sk-AFTER-ROTATION-XYZ"\n' + ), encoding="utf-8") + assert self._load(monkeypatch, home)["theta"].api_key == "sk-AFTER-ROTATION-XYZ" + + def test_cache_is_still_mutated_in_place(self, tmp_path, monkeypatch): + """Callers hold this exact dict (#16767) — reloading must not rebind.""" + home = self._profile(tmp_path, "inplace", ( + 'model_aliases:\n' + ' theta:\n model: m\n provider: custom\n' + ' base_url: "https://h.example.com/v1"\n' + )) + import hermes_cli.model_switch as ms + + before = id(ms.DIRECT_ALIASES) + self._load(monkeypatch, home) + assert id(ms.DIRECT_ALIASES) == before + + +class TestOneShotUsesTheSameHostInvariant: + """`hermes chat -m ` must not trust the alias's provider label. + + These exercise the REAL resolver — the leak lives inside + resolve_runtime_provider's provider-specific branches, so stubbing it + would test nothing. + """ + + @pytest.mark.parametrize("provider, env_var", [ + ("anthropic", "ANTHROPIC_API_KEY"), + ("deepseek", "DEEPSEEK_API_KEY"), + ("xai", "XAI_API_KEY"), + ]) + def test_no_key_alias_on_a_foreign_host_gets_no_provider_token( + self, monkeypatch, provider, env_var + ): + secret = f"sk-{provider}-LIVE-TOKEN" + monkeypatch.setenv(env_var, secret) + monkeypatch.setattr( + "hermes_cli.runtime_provider.load_config", + lambda *a, **k: {"model": {"default": "m", "provider": "openrouter"}}, + ) + from hermes_cli.model_switch import DirectAlias, direct_alias_runtime_request + from hermes_cli.runtime_provider import resolve_runtime_provider + + alias = DirectAlias("c", provider, "https://evil.test/v1") + requested, explicit_key = direct_alias_runtime_request(alias) + runtime = resolve_runtime_provider( + requested=requested, + explicit_api_key=explicit_key, + explicit_base_url=alias.base_url, + ) + assert runtime.get("api_key") != secret + + def test_label_is_kept_when_the_alias_has_no_url(self): + """Nothing to protect against without a foreign host, and the label is + the only routing information there is.""" + from hermes_cli.model_switch import DirectAlias, direct_alias_runtime_request + + assert direct_alias_runtime_request( + DirectAlias("c", "anthropic", "") + ) == ("anthropic", None) + + def test_url_bearing_alias_is_forced_to_custom(self): + from hermes_cli.model_switch import DirectAlias, direct_alias_runtime_request + + assert direct_alias_runtime_request( + DirectAlias("c", "anthropic", "https://evil.test/v1") + ) == ("custom", None) + + def test_declared_key_is_carried_through(self, monkeypatch): + from hermes_cli.model_switch import DirectAlias, direct_alias_runtime_request + + assert direct_alias_runtime_request( + DirectAlias("c", "anthropic", "https://evil.test/v1", "sk-own") + ) == ("custom", "sk-own") + + +class TestBaseUrlOrigin: + """The origin helper the reuse decision is built on.""" + + @pytest.mark.parametrize("url, expected", [ + ("https://h/v1", ("https", "h", 443)), + ("https://h:443/v1", ("https", "h", 443)), + ("http://h/v1", ("http", "h", 80)), + ("https://h:8443/v1", ("https", "h", 8443)), + ("https://H./v1", ("https", "h", 443)), + ("", ("", "", 0)), + ("https://h:99999/v1", ("", "", 0)), + ]) + def test_origin_normalisation(self, url, expected): + from utils import base_url_origin + + assert base_url_origin(url) == expected + + +# --------------------------------------------------------------------------- +# Host gating in the direct-alias runtime branch +# --------------------------------------------------------------------------- + +class TestDirectAliasHostGating: + @pytest.mark.parametrize( + "base_url, expect_key", + [ + ("https://ollama.com/v1", True), + # Look-alike and path-embedded hosts must NOT get the credential + # (GHSA-76xc-57q6-vm5m). + ("https://ollama.com.attacker.test/v1", False), + ("http://127.0.0.1/ollama.com/v1", False), + ], + ) + def test_ollama_key_is_host_matched_not_substring_matched( + self, monkeypatch, base_url, expect_key + ): + monkeypatch.setenv("OLLAMA_API_KEY", "sk-ollama-KEY") + from hermes_cli.runtime_provider import _resolve_named_custom_runtime + + runtime = _resolve_named_custom_runtime( + requested_provider="custom", explicit_base_url=base_url + ) + assert (runtime["api_key"] == "sk-ollama-KEY") is expect_key + + +# --------------------------------------------------------------------------- +# hermes chat -m — the oneshot path +# --------------------------------------------------------------------------- + +class TestOneshotPassesAliasCredential: + def test_alias_api_key_is_passed_to_the_resolver(self, monkeypatch): + """``hermes chat -m theta`` must hand the alias's key to + resolve_runtime_provider, not leave it to env fallbacks.""" + from hermes_cli.model_switch import DirectAlias + import hermes_cli.model_switch as ms + + monkeypatch.setattr( + ms, + "DIRECT_ALIASES", + {"theta": DirectAlias("theta-1", "custom", ALIAS_HOST, "sk-theta-ALIAS")}, + ) + monkeypatch.setattr(ms, "_ensure_direct_aliases", lambda: None) + + captured = {} + + def _fake_resolve(**kwargs): + captured.update(kwargs) + raise RuntimeError("stop after credential resolution") + + # oneshot imports the resolver inside the function, so patch it at + # its source module. + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", _fake_resolve + ) + monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: {}) + import hermes_cli.oneshot as oneshot + + # _run_agent holds the alias wiring; run_oneshot() wraps it in a + # catch-all that would swallow the sentinel. + with pytest.raises(RuntimeError, match="stop after credential resolution"): + oneshot._run_agent(prompt="hi", model="theta") + + assert captured["explicit_base_url"] == ALIAS_HOST + assert captured["explicit_api_key"] == "sk-theta-ALIAS" + + +class TestNoProductionCodeMutatesTheAliasCacheInPlace: + """The profile-isolation property depends on an unwritten rule. + + ``_ensure_direct_aliases`` keeps a copy of what it loaded and treats any + divergence as a caller's data, not its own stale cache — that is what lets + tests seed ``DIRECT_ALIASES`` in place without the loader wiping them + (#16767). The cost is that a *production* in-place mutation would pin the + cache: contents would never again match the copy, so the config-identity + check that reloads on a profile switch would stop being consulted, and one + profile's aliases and credentials would be served to the next. + + No production code mutates it today — only the loader itself, and every + other reference is a read. This pins that, because the failure mode is + silent: nothing raises, nothing logs, and the leak only shows up as one + profile answering with another profile's key. + """ + + #: The only place allowed to write the cache. + OWNER = ("hermes_cli/model_switch.py", "_ensure_direct_aliases") + + MUTATORS = frozenset( + {"update", "clear", "pop", "popitem", "setdefault", "__setitem__"} + ) + + @staticmethod + def _production_sources(): + import pathlib + + repo = pathlib.Path(__file__).resolve().parents[2] + skip = {".git", "node_modules", "tests", "build", "dist", ".venv"} + for path in repo.rglob("*.py"): + if any(part in skip for part in path.parts): + continue + yield path, path.relative_to(repo).as_posix() + + @classmethod + def _violations(cls, source: str, rel: str): + """Yield (function, description) for each in-place write.""" + import ast + + tree = ast.parse(source) + enclosing = {} + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for child in ast.walk(node): + enclosing[id(child)] = node.name + + def _names(node): + """DIRECT_ALIASES, whether bare or attribute-qualified.""" + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + for node in ast.walk(tree): + where = enclosing.get(id(node), "") + if (rel, where) == cls.OWNER: + continue + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if ( + _names(node.func.value) == "DIRECT_ALIASES" + and node.func.attr in cls.MUTATORS + ): + yield where, f"DIRECT_ALIASES.{node.func.attr}()" + if isinstance(node, (ast.Assign, ast.AugAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if ( + isinstance(target, ast.Subscript) + and _names(target.value) == "DIRECT_ALIASES" + ): + yield where, "DIRECT_ALIASES[...] = ..." + + def test_only_the_loader_writes_the_cache(self): + found = [] + for path, rel in self._production_sources(): + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if "DIRECT_ALIASES" not in source: + continue + try: + found.extend( + f"{rel}:{where}: {what}" + for where, what in self._violations(source, rel) + ) + except SyntaxError: + continue + + assert not found, ( + "In-place writes to DIRECT_ALIASES outside " + f"{self.OWNER[0]}::{self.OWNER[1]} pin the alias cache and break " + "per-profile isolation:\n " + "\n ".join(found) + ) + + def test_the_scan_actually_detects_a_violation(self): + """Negative control — an always-passing scanner would prove nothing.""" + offending = ( + "from hermes_cli.model_switch import DIRECT_ALIASES\n" + "def warm():\n" + " DIRECT_ALIASES.update({'x': 1})\n" + " DIRECT_ALIASES['y'] = 2\n" + ) + + hits = list(self._violations(offending, "some/other_module.py")) + + assert {what for _, what in hits} == { + "DIRECT_ALIASES.update()", + "DIRECT_ALIASES[...] = ...", + } + assert all(where == "warm" for where, _ in hits) + + def test_the_owner_itself_is_exempt(self): + """...and an exemption that swallowed everything would prove nothing.""" + import pathlib + + repo = pathlib.Path(__file__).resolve().parents[2] + source = (repo / self.OWNER[0]).read_text(encoding="utf-8") + + assert list(self._violations(source, self.OWNER[0])) == [] + # The loader really does write in place, so the exemption is load-bearing. + assert "DIRECT_ALIASES.clear()" in source + assert "DIRECT_ALIASES.update(loaded)" in source diff --git a/utils.py b/utils.py index 33d4932302..7e9a76489a 100644 --- a/utils.py +++ b/utils.py @@ -903,6 +903,36 @@ def model_forces_max_completion_tokens(model: str) -> bool: ) +def base_url_origin(base_url: str) -> tuple[str, str, int]: + """Return ``(scheme, hostname, effective_port)`` for a base URL. + + Origin, not just host. ``https://h/v1`` and ``http://h/v1`` are different + trust boundaries, and so are two ports on the same host, so any decision + about handing a bearer secret to a new URL has to compare all three — + hostname equality alone would authorise an HTTPS→HTTP downgrade. + + The port is normalised to the scheme default (443/80) when absent, so + ``https://h`` and ``https://h:443`` compare equal. Returns + ``("", "", 0)`` when the URL yields no usable hostname or a bad port. + """ + raw = (base_url or "").strip() + if not raw: + return ("", "", 0) + parsed = urlparse(raw if "://" in raw else f"//{raw}") + scheme = (parsed.scheme or "").lower() + hostname = (parsed.hostname or "").lower().rstrip(".") + if not hostname: + return ("", "", 0) + try: + port = parsed.port + except ValueError: + # Out-of-range or non-numeric port — not a usable origin. + return ("", "", 0) + if port is None: + port = {"https": 443, "http": 80}.get(scheme, 0) + return (scheme, hostname, port) + + def base_url_host_matches(base_url: str, domain: str) -> bool: """Return True when the base URL's hostname is ``domain`` or a subdomain. diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 1dcc916782..3f17e773e7 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -197,8 +197,19 @@ model_aliases: model: qwen3-coder:30b provider: custom base_url: http://localhost:11434/v1 + theta: + model: theta-1 + provider: custom + base_url: https://theta.example.com/v1 + key_env: THETA_API_KEY # or: api_key: "${THETA_API_KEY}" ``` +An alias with its own `base_url` can carry that endpoint's credential via +`api_key` (a literal, or a `"${VAR}"` reference) or `key_env` (an environment +variable name); `api_key` wins if both are set. With neither set, the key is +resolved from the alias **host** +and never inherited from the provider that was active before the switch. + **Short form** — `provider/model` in one string. Set from the shell without editing YAML: ```bash diff --git a/website/docs/user-guide/configuring-models.md b/website/docs/user-guide/configuring-models.md index 3e8db68c31..4c5b750291 100644 --- a/website/docs/user-guide/configuring-models.md +++ b/website/docs/user-guide/configuring-models.md @@ -301,6 +301,26 @@ model_aliases: provider: x-ai ``` +An alias that points at its own endpoint can also carry that endpoint's +credential, with either `api_key` (a literal, or a `"${VAR}"` reference) or +`key_env` (the name of an environment variable). If both are set, `api_key` +wins: + +```yaml +model_aliases: + theta: + model: theta-1 + provider: custom + base_url: "https://theta.example.com/v1" + key_env: THETA_API_KEY # or: api_key: "${THETA_API_KEY}" +``` + +When an alias sets neither, the key is resolved from the alias **host** — +`OLLAMA_API_KEY` for an `ollama.com` endpoint, `DEEPSEEK_API_KEY` for +`api.deepseek.com`, and so on. It is never inherited from whichever provider +happened to be active before the switch, so switching to an alias cannot send +one provider's secret to another provider's host. + **Short string form (`model.aliases.: provider/model`)** — convenient from the shell because `hermes config set` writes scalars and now also parses inline list/mapping literals, though this short alias form still can't carry a custom `base_url`: ```bash