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