Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion gateway/slash_commands_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 "",
Expand Down
105 changes: 105 additions & 0 deletions tests/gateway/test_model_switch_provider_routing_display.py
Original file line number Diff line number Diff line change
@@ -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
)