fix(model_switch): a bare custom switch no longer adopts the OPENROUTER_BASE_URL mirror

The switched-provider bare-`custom` arm adopts the resolved endpoint unless it is the
OpenRouter default. `_fell_back_to_openrouter_default` only knew the built-in
`openrouter.ai` host, so an `OPENROUTER_BASE_URL` mirror — the credential ladder's last
rung, reached whenever no trusted `model.base_url`/`CUSTOM_BASE_URL` exists — looked like a
configured custom endpoint: a session on another provider switching to bare `custom` landed
on the mirror with the `no-key-required` placeholder, dropping the key it was using.

The helper now also recognizes that mirror (read the way the resolver reads it, through the
profile's secret scope), so the arm keeps the session's endpoint and key instead. Sharing the
helper also covers the same-provider #74143 path: a `custom`/`local` session on a session-only
endpoint no longer adopts the mirror either — the same "resolution landed on OpenRouter"
case that guard exists for.

The mirror read is a GUARD read, not a credential fetch: a scope failure (unscoped under
multiplexing) leaves the mirror undetected so the caller keeps the session endpoint, instead
of raising out of `switch_model` where the resolver's own read of the same name is suppressed.

`CUSTOM_BASE_URL` and a trusted `model.base_url` still win: those are endpoints configured for
`custom`, which is the point of the arm.
This commit is contained in:
kshitijk4poor
2026-09-19 21:42:42 +05:30
committed by kshitij
parent 7540079344
commit 3cfbffa16e
2 changed files with 67 additions and 3 deletions

View File

@@ -1383,7 +1383,8 @@ def _creds_for_switched_provider(st: _Switch) -> Optional[ModelSwitchResult]:
# ANOTHER provider (the per-turn config sync adopting ``provider: custom``) the configured
# endpoint wins, or the new model is paired with the old provider's host and key (#73680).
# With nothing configured the resolver either raises (st.* keep the session values) or
# lands on OpenRouter's default (#74143) — the session endpoint is kept in both cases.
# lands on OpenRouter's default or the ``OPENROUTER_BASE_URL`` mirror (#74143, #10622) —
# the session endpoint is kept in all three cases.
key, url = st.current_api_key, st.current_base_url
if st.current_provider != "custom":
with suppress(Exception):
@@ -1457,12 +1458,29 @@ def _creds_for_current_provider(st: _Switch) -> None:
def _fell_back_to_openrouter_default(st: _Switch) -> bool:
"""The bare-``custom`` resolver ended on OpenRouter's default host while the session was
elsewhere: no trusted ``model.base_url`` existed, so the URL is one the user never picked."""
"""The bare-``custom`` resolver ended on an OpenRouter endpoint that is not a custom endpoint
the user configured: the built-in default host, or the ``OPENROUTER_BASE_URL`` mirror — the
credential ladder's last rung (#10622), which ``provider: custom`` reaches whenever no
trusted ``model.base_url`` / ``CUSTOM_BASE_URL`` exists."""
mirror = _openrouter_mirror_base_url()
if mirror and st.base_url.rstrip("/") == mirror:
return True
return (base_url_host_matches(st.base_url, "openrouter.ai")
and not base_url_host_matches(st.current_base_url, "openrouter.ai"))
def _openrouter_mirror_base_url() -> str:
"""``OPENROUTER_BASE_URL``, read the way the resolver reads it (env, or the profile's secret
scope). A guard read, not a credential fetch: a read that fails — unscoped under multiplexing —
must leave the mirror undetected so its caller keeps the session endpoint, rather than raising
out of ``switch_model`` where the resolver's own read of the same name is suppressed."""
from agent.secret_scope import get_secret_str
try:
return (get_secret_str("OPENROUTER_BASE_URL", "") or "").strip().rstrip("/")
except Exception:
return ""
def _resolve_switch_credentials(st: _Switch) -> Optional[ModelSwitchResult]:
"""COMMON PATH part 1: credentials, direct-alias endpoint override, and the api_mode for the
final (provider, base_url) before validation."""

View File

@@ -561,6 +561,52 @@ def test_switch_to_bare_custom_with_no_configured_endpoint_keeps_the_current_one
assert (result.base_url, result.api_key) == ("https://api.anthropic.com", "sk-ant")
def test_openrouter_mirror_read_never_raises_without_a_secret_scope(monkeypatch):
"""The mirror guard reads ``OPENROUTER_BASE_URL`` through the profile secret scope: with
multiplexing on and no scope installed that read raises ``UnscopedSecretError``. A guard read
must degrade to 'no mirror detected' (the caller then keeps the session endpoint) instead of
propagating out of ``switch_model``, where the resolver's own read of the same name is
suppressed."""
from agent import secret_scope
from hermes_cli.model_switch import _openrouter_mirror_base_url
monkeypatch.setenv("OPENROUTER_BASE_URL", "https://mirror.example.com/v1")
secret_scope.set_multiplex_active(True)
try:
assert _openrouter_mirror_base_url() == ""
finally:
secret_scope.set_multiplex_active(False)
def test_switch_to_bare_custom_ignores_an_openrouter_mirror(monkeypatch, tmp_path):
"""#115661 follow-up: with ``OPENROUTER_BASE_URL`` set to a mirror and nothing configured for
``custom``, the ladder's last rung hands back that mirror — a host the user configured for
OpenRouter — with the ``no-key-required`` placeholder. It must not replace the session's own
endpoint and key (the switched arm used to adopt it, dropping a working credential)."""
home = tmp_path / "hermes-home"
home.mkdir()
(home / "config.yaml").write_text("model:\n default: m\n provider: custom\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("OPENROUTER_BASE_URL", "https://mirror.example.com/v1")
monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
result = switch_model(
raw_input="m2",
current_provider="anthropic",
current_model="m",
current_base_url="https://api.anthropic.com",
current_api_key="sk-ant",
explicit_provider="custom",
user_providers={},
custom_providers=[],
)
assert result.success is True
assert (result.base_url, result.api_key) == ("https://api.anthropic.com", "sk-ant")
def test_is_aggregator_recognizes_named_custom_provider():
assert providers_mod.is_aggregator("custom:hpc-ai") is True
assert providers_mod.is_aggregator("custom:litellm") is True