Skip to content
Closed
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
58 changes: 18 additions & 40 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1732,6 +1732,10 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
user_provs = None
custom_provs = None
excluded_provs = []
configured_model = None
configured_provider = None
configured_base_url = None
configured_context_length = None
config_path = (_command_profile_home or _hermes_home) / "config.yaml"
try:
cfg = _load_gateway_config()
Expand All @@ -1741,6 +1745,12 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
current_model = model_cfg.get("default", "")
current_provider = model_cfg.get("provider", current_provider)
current_base_url = model_cfg.get("base_url", "")
configured_model = model_cfg.get("default") or model_cfg.get("model")
configured_provider = model_cfg.get("provider")
configured_base_url = model_cfg.get("base_url")
raw_context_length = model_cfg.get("context_length")
if raw_context_length is not None:
configured_context_length = int(raw_context_length)
user_provs = cfg.get("providers")
try:
from hermes_cli.config import get_compatible_custom_providers
Expand Down Expand Up @@ -2016,33 +2026,17 @@ async def _on_model_selected_scoped(
lines.append(t("gateway.model.provider_label", provider=plabel))
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length_async
_sw_config_ctx = None
_sw_model_cfg = {}
try:
_sw_cfg = _load_gateway_config()
_sw_model_cfg = _sw_cfg.get("model", {})
if isinstance(_sw_model_cfg, dict):
_sw_raw = _sw_model_cfg.get("context_length")
if _sw_raw is not None:
_sw_config_ctx = int(_sw_raw)
except Exception:
pass
if not isinstance(_sw_model_cfg, dict):
_sw_model_cfg = {}
ctx = await resolve_display_context_length_async(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=mi,
custom_providers=custom_provs,
config_context_length=_sw_config_ctx,
configured_model=(
_sw_model_cfg.get("default")
or _sw_model_cfg.get("model")
),
configured_provider=_sw_model_cfg.get("provider"),
configured_base_url=_sw_model_cfg.get("base_url"),
config_context_length=configured_context_length,
configured_model=configured_model,
configured_provider=configured_provider,
configured_base_url=configured_base_url,
)
if ctx:
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
Expand Down Expand Up @@ -2342,33 +2336,17 @@ async def _finish_switch() -> str:
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length_async
_sw2_config_ctx = None
_sw2_model_cfg = {}
try:
_sw2_cfg = _load_gateway_config()
_sw2_model_cfg = _sw2_cfg.get("model", {})
if isinstance(_sw2_model_cfg, dict):
_sw2_raw = _sw2_model_cfg.get("context_length")
if _sw2_raw is not None:
_sw2_config_ctx = int(_sw2_raw)
except Exception:
pass
if not isinstance(_sw2_model_cfg, dict):
_sw2_model_cfg = {}
ctx = await resolve_display_context_length_async(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=mi,
custom_providers=custom_provs,
config_context_length=_sw2_config_ctx,
configured_model=(
_sw2_model_cfg.get("default")
or _sw2_model_cfg.get("model")
),
configured_provider=_sw2_model_cfg.get("provider"),
configured_base_url=_sw2_model_cfg.get("base_url"),
config_context_length=configured_context_length,
configured_model=configured_model,
configured_provider=configured_provider,
configured_base_url=configured_base_url,
)
if ctx:
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
Expand Down
151 changes: 151 additions & 0 deletions tests/gateway/test_model_command_context_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Regression tests for context-length display during gateway /model switches."""

from __future__ import annotations

import pytest
import yaml

from gateway.config import Platform
from gateway.platforms.base import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource


def _make_runner():
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner._voice_mode = {}
runner._session_model_overrides = {}
runner._running_agents = {}
return runner


def _make_event(text: str) -> MessageEvent:
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.DISCORD,
chat_id="12345",
chat_type="thread",
thread_id="12345",
),
)


def _codex_switch_result():
from hermes_cli.model_switch import ModelSwitchResult

return ModelSwitchResult(
success=True,
new_model="gpt-5.6-sol",
target_provider="openai-codex",
provider_changed=True,
api_key="codex-token",
base_url="https://chatgpt.com/backend-api/codex",
api_mode="codex_responses",
provider_label="OpenAI Codex",
is_global=False,
)


def _write_pinned_profile(hermes_home) -> None:
"""Globally pin the profile to a 1M-token model on a different route."""
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({
"model": {
"default": "glm-5.2",
"provider": "zai",
"context_length": 1_000_000,
},
"providers": {},
}),
encoding="utf-8",
)


def _apply_common_mocks(monkeypatch, hermes_home) -> None:
"""Point every hermes-home lookup at *hermes_home* and stub the switch."""
import gateway.run as gateway_run

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(
"hermes_cli.model_switch.switch_model", lambda **kw: _codex_switch_result()
)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning", lambda *a, **kw: None
)


def _read_profile(hermes_home) -> dict:
return yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8"))


@pytest.mark.asyncio
async def test_session_model_switch_does_not_reuse_global_context_override(
tmp_path, monkeypatch
):
"""A session-only Codex switch must display Codex's real 272K cap.

The profile can be globally configured for a different 1M-token model.
Before the fix, the confirmation message reused that stale global
``model.context_length`` and claimed Codex gpt-5.6-sol had 1,000,000 tokens.

``--session`` opts out of persistence, so the originally configured route
must also survive the switch unchanged on disk.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_write_pinned_profile(hermes_home)
_apply_common_mocks(monkeypatch, hermes_home)

result = await _make_runner()._handle_model_command(
_make_event("/model gpt-5.6-sol --provider openai-codex --session")
)

assert result is not None
assert "gpt-5.6-sol" in result
assert "272,000" in result
assert "1,000,000" not in result

# Session-only: the configured route is left untouched on disk.
persisted = _read_profile(hermes_home)["model"]
assert persisted["default"] == "glm-5.2"
assert persisted["provider"] == "zai"
assert persisted["context_length"] == 1_000_000


@pytest.mark.asyncio
async def test_global_model_switch_drops_stale_context_pin(tmp_path, monkeypatch):
"""A persistent (--global) switch to a new route must not inherit the pin.

hermes-sweeper (#48187): the original guard reloaded config after the
switch had already written ``model.default``/``model.provider``, saw the
new target route, and accepted the inherited 1M ``context_length`` for
persistent switches. The confirmation must show Codex's real 272K cap
(the session-only check uses pre-switch route state), and the persisted
pin must be dropped because its route no longer matches — the global
switch stays explicitly scoped instead of inheriting the stale override.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_write_pinned_profile(hermes_home)
_apply_common_mocks(monkeypatch, hermes_home)

result = await _make_runner()._handle_model_command(
_make_event("/model gpt-5.6-sol --provider openai-codex --global")
)

assert result is not None
assert "gpt-5.6-sol" in result
# Display resolves against the pre-switch route, so the stale 1M pin is cleared.
assert "272,000" in result
assert "1,000,000" not in result

# Persisted route moved to Codex; the route-mismatched pin is dropped, not inherited.
persisted = _read_profile(hermes_home)["model"]
assert persisted["default"] == "gpt-5.6-sol"
assert persisted["provider"] == "openai-codex"
assert "context_length" not in persisted