diff --git a/gateway/slash_commands_model.py b/gateway/slash_commands_model.py index 1491e62fb675d..f1013a9d58051 100644 --- a/gateway/slash_commands_model.py +++ b/gateway/slash_commands_model.py @@ -11,7 +11,9 @@ import asyncio import contextlib import dataclasses +import json import logging +from types import SimpleNamespace from typing import Any, Optional from agent.i18n import t @@ -34,6 +36,29 @@ # /reasoning display-toggle arguments -> show_reasoning value. _REASONING_DISPLAY_TOGGLES = {"show": True, "on": True, "hide": False, "off": False} +def _configured_provider_routing_line(routing: dict, model: str) -> Optional[str]: + """Render validated request routing without implying which provider served.""" + from agent.chat_completion_helpers import _provider_preferences_for_agent + + if not isinstance(routing, dict): + return None + effective = _provider_preferences_for_agent(SimpleNamespace( + model=model, + providers_allowed=routing.get("only"), + providers_ignored=routing.get("ignore"), + providers_order=routing.get("order"), + provider_sort=routing.get("sort"), + provider_require_parameters=routing.get("require_parameters"), + provider_data_collection=routing.get("data_collection"), + )) + if not effective: + return None + try: + encoded = json.dumps(effective, ensure_ascii=False, separators=(",", ":")) + except (TypeError, ValueError): + return None + return f"provider_routing: {encoded}" + def _model_switch_skew_guard() -> Optional[str]: """Refuse a model switch when the gateway is running stale code: a first-time lazy import on @@ -273,14 +298,26 @@ async def _model_switch_confirmation( ] # Provider-aware chain: Codex OAuth, Copilot and Nous caps win over the raw models.dev entry. mi = result.model_info + gateway_cfg: dict = {} model_cfg: dict = {} config_ctx = None with contextlib.suppress(Exception): # fail-open on config read errors - model_cfg = _load_gateway_config().get("model", {}) + gateway_cfg = _load_gateway_config() + model_cfg = gateway_cfg.get("model", {}) if isinstance(model_cfg, dict) and model_cfg.get("context_length") is not None: config_ctx = int(model_cfg["context_length"]) if not isinstance(model_cfg, dict): model_cfg = {} + if ( + result.target_provider == "openrouter" + or base_url_host_matches(result.base_url or "", "openrouter.ai") + ): + routing_line = _configured_provider_routing_line( + getattr(self, "_provider_routing", {}), + result.new_model, + ) + if routing_line: + lines.append(routing_line) ctx_len = await resolve_display_context_length_async( result.new_model, result.target_provider, base_url=result.base_url or ctx.current_base_url or "", diff --git a/tests/gateway/test_model_switch_provider_routing_display.py b/tests/gateway/test_model_switch_provider_routing_display.py new file mode 100644 index 0000000000000..79cfdde636ab5 --- /dev/null +++ b/tests/gateway/test_model_switch_provider_routing_display.py @@ -0,0 +1,105 @@ +"""Gateway model-switch confirmations expose configured OpenRouter routing. + +Regression for #110784. +""" + +from datetime import date +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import Platform +from gateway.platforms.event import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource +from gateway.slash_commands_model import _configured_provider_routing_line + + +@pytest.mark.parametrize("routing", [{"sort": "bogus"}, {"only": [date(2026, 1, 1)]}]) +def test_routing_display_omits_values_the_request_path_cannot_emit(monkeypatch, routing): + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {}) + + assert _configured_provider_routing_line(routing, "test/model") is None + + +@pytest.mark.asyncio +async def test_model_command_reads_routing_from_profile_config(tmp_path, monkeypatch): + import gateway.run as gateway_run + from hermes_cli.model_switch import ModelSwitchResult + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + """ +model: + default: old-model + provider: openrouter +provider_routing: + sort: price + models: + "z-ai/glm-5.3:exacto": + only: [fireworks] + data_collection: deny +""".lstrip(), + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home) + monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home) + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr( + "hermes_cli.model_cost_guard.expensive_model_warning", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + "hermes_cli.model_switch.switch_model", + lambda **_kwargs: ModelSwitchResult( + success=True, + new_model="z-ai/glm-5.3:exacto", + target_provider="openrouter", + provider_changed=False, + api_key="test", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + provider_label="OpenRouter", + ), + ) + + async def _no_context(*_args, **_kwargs): + return None + + monkeypatch.setattr( + "hermes_cli.model_switch.resolve_display_context_length_async", + _no_context, + ) + + runner = object.__new__(GatewayRunner) + runner.adapters = {} + runner._voice_mode = {} + runner._session_model_overrides = {} + runner._pending_one_turn_model_restores = {} + runner._running_agents = {} + runner._provider_routing = {"sort": " Throughput "} + store = MagicMock() + store.set_model_override = AsyncMock() + store._store = None + setattr(runner, "session_store", None) + runner._async_session_store = store + event = MessageEvent( + text="/model glm53", + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.SLACK, + user_id="user", + chat_id="channel", + chat_type="group", + ), + ) + + confirmation = await runner._handle_model_command(event) + + assert confirmation is not None + assert ( + 'provider_routing: {"only":["fireworks"],"sort":"throughput",' + '"data_collection":"deny"}' in confirmation + )