From 3cfbffa16e0601e71b41e8b5d3ff7194d96ca73f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:42:42 +0530 Subject: [PATCH] fix(model_switch): a bare custom switch no longer adopts the OPENROUTER_BASE_URL mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/model_switch.py | 24 ++++++++-- .../test_model_switch_custom_providers.py | 46 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index e753a8d8c7..0acecb679d 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -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.""" diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 1e62aa45d9..a733df602b 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -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