diff --git a/cli.py b/cli.py index 6f1a537e89ff..8850a422831e 100644 --- a/cli.py +++ b/cli.py @@ -8174,17 +8174,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): def _clear_persisted_context_for_model_switch(self, result) -> None: """Drop a global context pin when its configured owner changes.""" try: - from agent.agent_init import _context_route_mismatch from hermes_cli.config import load_config_readonly + from hermes_cli.route_identity import should_clear_context_pin config = load_config_readonly() model_cfg = config.get("model", {}) if isinstance(config, dict) else {} if not isinstance(model_cfg, dict) or "context_length" not in model_cfg: return - configured_model = model_cfg.get("default") or model_cfg.get("model") - if ( - configured_model and configured_model != result.new_model - ) or _context_route_mismatch( + if should_clear_context_pin( + model_cfg.get("default") or model_cfg.get("model"), + result.new_model, model_cfg.get("base_url"), result.base_url, model_cfg.get("provider"), diff --git a/gateway/run.py b/gateway/run.py index 5cf40063c6e8..0bbb976258cd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11959,9 +11959,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _msg_config_ctx = None if _msg_config_ctx is not None and isinstance(_msg_model_cfg, dict): try: - from agent.agent_init import _context_route_mismatch + from hermes_cli.route_identity import should_clear_context_pin - if _context_route_mismatch( + if should_clear_context_pin( + None, # model match already checked above + None, _msg_model_cfg.get("base_url"), _msg_base_url, _msg_model_cfg.get("provider"), @@ -12524,12 +12526,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _hyg_config_context_length is not None: try: - from agent.agent_init import _context_route_mismatch + from hermes_cli.route_identity import should_clear_context_pin - if ( - _hyg_configured_model - and _hyg_model != _hyg_configured_model - ) or _context_route_mismatch( + if should_clear_context_pin( + _hyg_configured_model, + _hyg_model, _hyg_configured_base_url, _hyg_base_url, _hyg_configured_provider, @@ -13859,11 +13860,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if config_context_length is not None: try: - from agent.agent_init import _context_route_mismatch + from hermes_cli.route_identity import should_clear_context_pin - if ( - configured_model and model != configured_model - ) or _context_route_mismatch( + if should_clear_context_pin( + configured_model, + model, configured_base_url, base_url, configured_provider, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index bc36b9ae7536..d332516137a7 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -1730,16 +1730,12 @@ class GatewaySlashCommandsMixin: _persist_model_cfg = {} _persist_cfg["model"] = _persist_model_cfg try: - from agent.agent_init import _context_route_mismatch + from hermes_cli.route_identity import should_clear_context_pin - _old_default = ( + if should_clear_context_pin( _persist_model_cfg.get("default") - or _persist_model_cfg.get("model") - ) - if ( - _old_default - and _old_default != result.new_model - ) or _context_route_mismatch( + or _persist_model_cfg.get("model"), + result.new_model, _persist_model_cfg.get("base_url"), result.base_url, _persist_model_cfg.get("provider"), @@ -2062,12 +2058,11 @@ class GatewaySlashCommandsMixin: model_cfg = {} cfg["model"] = model_cfg try: - from agent.agent_init import _context_route_mismatch + from hermes_cli.route_identity import should_clear_context_pin - old_default = model_cfg.get("default") or model_cfg.get("model") - if ( - old_default and old_default != result.new_model - ) or _context_route_mismatch( + if should_clear_context_pin( + model_cfg.get("default") or model_cfg.get("model"), + result.new_model, model_cfg.get("base_url"), result.base_url, model_cfg.get("provider"), diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 2ed64d0c1da5..fa83973622a8 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -863,11 +863,11 @@ def resolve_display_context_length( configured_model or configured_provider or configured_base_url ): try: - from agent.agent_init import _context_route_mismatch + from hermes_cli.route_identity import should_clear_context_pin - if ( - configured_model and configured_model != model - ) or _context_route_mismatch( + if should_clear_context_pin( + configured_model, + model, configured_base_url, base_url, configured_provider, diff --git a/hermes_cli/route_identity.py b/hermes_cli/route_identity.py index d49db114cc6c..cb64a3ed8173 100644 --- a/hermes_cli/route_identity.py +++ b/hermes_cli/route_identity.py @@ -45,3 +45,32 @@ def normalize_route_base_url(base_url: Any) -> str: if had_query_delimiter and not parsed.query: normalized += "?" return normalized + + +def should_clear_context_pin( + configured_model: Any, + active_model: Any, + configured_base_url: Any, + active_base_url: Any, + configured_provider: Any, + active_provider: Any, +) -> bool: + """True when a configured ``model.context_length`` pin no longer matches its runtime route. + + Fail-closed: any error during route comparison returns ``True`` (drop the pin) + so a stale window never silently inflates the compression threshold. + """ + configured_model = str(configured_model or "").strip() + if configured_model and configured_model != str(active_model or "").strip(): + return True + try: + from agent.agent_init import _context_route_mismatch + + return _context_route_mismatch( + configured_base_url, + active_base_url, + configured_provider, + active_provider, + ) + except Exception: + return True diff --git a/run_agent.py b/run_agent.py index 68387d7a05de..b92ea16d0a55 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4770,18 +4770,18 @@ class AIAgent: pass self._anthropic_api_key = runtime_key - self._anthropic_base_url = runtime_base + self._anthropic_base_url = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base self._anthropic_client = build_anthropic_client( - runtime_key, runtime_base, + runtime_key, self._anthropic_base_url, timeout=get_provider_request_timeout(self.provider, self.model), ) self._is_anthropic_oauth = _is_oauth_token(runtime_key) if self.provider == "anthropic" else False self.api_key = runtime_key - self.base_url = runtime_base + self.base_url = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base return self.api_key = runtime_key - self.base_url = runtime_base + self.base_url = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base self._client_kwargs["api_key"] = self.api_key self._client_kwargs["base_url"] = self.base_url self._client_kwargs.pop("ssl_verify", None) diff --git a/tests/run_agent/test_credential_rotation_route_settings.py b/tests/run_agent/test_credential_rotation_route_settings.py index 92c0e1dab514..572608b7a079 100644 --- a/tests/run_agent/test_credential_rotation_route_settings.py +++ b/tests/run_agent/test_credential_rotation_route_settings.py @@ -104,6 +104,8 @@ def test_credential_rotation_does_not_carry_global_headers_across_routes(): def test_credential_rotation_preserves_route_significant_trailing_segments(): + """Route identity comparison uses normalize_route_base_url, but the stored + base_url is stripped like every other assignment site (__init__, switch_model).""" agent = SimpleNamespace( api_mode="chat_completions", provider="custom", @@ -127,5 +129,5 @@ def test_credential_rotation_preserves_route_significant_trailing_segments(): with patch("hermes_cli.config.load_config_readonly", return_value={}): AIAgent._swap_credential(agent, entry) - assert agent.base_url == "https://b.example/v1//" - assert agent._client_kwargs["base_url"] == "https://b.example/v1//" + assert agent.base_url == "https://b.example/v1" + assert agent._client_kwargs["base_url"] == "https://b.example/v1"