Skip to content
Merged
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
18 changes: 16 additions & 2 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ def _stale_code_switch_guard_enabled() -> bool:
return _STALE_CODE_SWITCH_GUARD_DEFAULT


def _with_resolved_route(label: str, result: Any) -> str:
"""Append the resolved ``provider/model`` pair to a /model confirmation.

The display label alone ("Claude BPX-5") hides a mis-split such as
``claude-apr`` + ``name:claude-bpx-5/x``; the literal pair makes it
visible in the reply on every platform.
"""
provider = str(getattr(result, "target_provider", "") or "").strip()
model = str(getattr(result, "new_model", "") or "").strip()
if not provider or not model:
return label
return f"{label} · `{provider}/{model}`"


def _model_switch_skew_guard() -> Optional[str]:
"""Refuse a model switch when the gateway is running stale code.

Expand Down Expand Up @@ -2853,7 +2867,7 @@ async def _on_model_selected(
_fast_row = self._fast_unavailable_model_switch_row(result)
if _fast_row:
lines.append(_fast_row)
lines.append(t("gateway.model.provider_label", provider=plabel))
lines.append(t("gateway.model.provider_label", provider=_with_resolved_route(plabel, result)))
try:
# Read the ACTUAL post-switch effort off the agent
# (#467 rule 3) rather than re-resolving for the OLD
Expand Down Expand Up @@ -3276,7 +3290,7 @@ async def _finish_switch() -> str:
# Build confirmation message with full metadata
provider_label = result.provider_label or result.target_provider
lines = [t("gateway.model.switched", model=format_model_for_display(result.new_model))]
lines.append(t("gateway.model.provider_label", provider=provider_label))
lines.append(t("gateway.model.provider_label", provider=_with_resolved_route(provider_label, result)))
_fast_row = self._fast_unavailable_model_switch_row(result)
if _fast_row:
lines.append(_fast_row)
Expand Down
65 changes: 65 additions & 0 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,18 @@ def parse_model_flags_detailed(raw_args: str) -> ModelFlagParseResult:
filtered.append(parts[i])
i += 1

# Discord's native /model renders its option as ``name:<value>``; pasting
# that text into any other surface (Telegram, Slack, CLI, TUI) used to
# make ``name:claude-bpx-5/x`` the model id under the CURRENT provider.
# Strip a leading option label here, once, for every surface.
if filtered:
_opt = _re.match(r"(?i)^(?:name|model)[:=](.*)$", filtered[0])
if _opt is not None:
if _opt.group(1):
filtered[0] = _opt.group(1)
else: # "name: <value>" — label token on its own
filtered.pop(0)

model_input = " ".join(filtered).strip()
return ModelFlagParseResult(
model_input=model_input,
Expand Down Expand Up @@ -1799,6 +1811,25 @@ def switch_model(
if inline_provider is not None:
target_provider, new_model = inline_provider

# ``X/model`` off an aggregator where X is neither a provider nor a vendor
# namespace: X is most likely a mistyped/unconfigured provider. Remember
# it so the switch is refused (below, after validation) instead of
# persisting ``<current>/X/model`` — unless the current endpoint lists or
# config declares the full slug (HF-style ids on private endpoints).
unknown_slash_prefix = ""
if not explicit_provider and inline_provider is None:
_raw_slug = raw_input.strip()
_head = _raw_slug.split("/", 1)[0].strip()
if (
"/" in _raw_slug
and _head
and "://" not in _raw_slug
and not is_aggregator(current_provider)
and not is_known_vendor_namespace(_head)
and not _user_provider_lists_model(_raw_slug, current_provider, user_providers)
):
unknown_slash_prefix = _head

# =================================================================
# PATH A: Explicit --provider given OR inline provider qualification
# =================================================================
Expand Down Expand Up @@ -2450,6 +2481,40 @@ def switch_model(
error_message=msg,
)

# Refuse ``unknown-provider/model`` rather than persisting a route whose
# model id silently embeds a provider name nothing can resolve. Only when
# the id survived resolution untouched AND the endpoint did not recognise
# it — a listed/declared slug is a real model id and passes.
if (
unknown_slash_prefix
and target_provider == current_provider
and new_model.strip().split("/", 1)[0].strip() == unknown_slash_prefix
and not validation.get("recognized")
and not validation.get("corrected_model")
and not any(
isinstance(_cp, dict)
and (
_cp.get("model") == new_model
or new_model in _declared_model_ids(_cp.get("models", {}))
)
for _cp in (custom_providers if isinstance(custom_providers, list) else [])
)
):
return ModelSwitchResult(
success=False,
new_model=new_model,
target_provider=target_provider,
provider_label=provider_label,
is_global=is_global,
error_message=(
f"Unknown provider '{unknown_slash_prefix}' in "
f"'{new_model}'. No model switch was made. Use "
f"<provider>/<model> with a configured provider id "
f"(see 'hermes model'), or force the full id on the current "
f"provider with --provider {current_provider}."
),
)

# Apply auto-correction if validation found a closer match
if validation.get("corrected_model"):
new_model = validation["corrected_model"]
Expand Down
145 changes: 145 additions & 0 deletions tests/gateway/test_model_command_discord_name_form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Telegram receiving the literal Discord-rendered ``/model name:<provider>/<model>``
must switch provider AND model, confirm the resolved pair, and change nothing
when the provider is unknown.

Repro (2026-09-24, Ace): on Telegram, current provider claude-apr,
``/model name:claude-bpx-5/claude-fable-5-1`` left the session on
``claude-apr/name:claude-bpx-5/claude-fable-5-1``.

The gateway handler drives the REAL parser and the REAL switch_model; only the
network-touching resolution steps are patched.
"""

import threading
import types
from unittest.mock import patch

import pytest

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._pending_model_notes = {}
runner._agent_cache = {}
runner._agent_cache_lock = threading.Lock()
runner._session_db = None
runner._evict_cached_agent = lambda _session_key: None
runner.session_store = None
return runner


def _telegram_event(text):
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.TELEGRAM,
chat_id="571820863",
chat_type="dm",
user_id="user-1",
),
)


@pytest.fixture
def runner_env(tmp_path, monkeypatch):
import gateway.run as gateway_run

hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"model:\n default: claude-opus-5\n provider: my-apr\n"
"providers:\n"
" my-apr:\n base_url: http://apr.invalid/v1\n models:\n claude-opus-5: {}\n"
" my-bpx:\n base_url: http://bpx.invalid/v1\n models:\n claude-fable-5-1: {}\n",
encoding="utf-8",
)
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})

runner = _make_runner()
calls = []
agent = types.SimpleNamespace(
model="claude-opus-5",
provider="my-apr",
reasoning_config=None,
context_compressor=None,
switch_model=lambda **kw: calls.append(kw),
)

async def _noop(*_a, **_k):
return None

runner._announce_model_switch = _noop
runner._announce_switch = _noop
runner._switch_reasoning_kwargs = lambda **_k: {}
return runner, agent, calls


def _patched_resolution(validation):
return [
patch("hermes_cli.model_switch.resolve_alias", return_value=None),
patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
patch("hermes_cli.model_switch.normalize_model_for_provider",
side_effect=lambda model, provider: model),
patch("hermes_cli.models.validate_requested_model", return_value=validation),
patch("hermes_cli.models.detect_provider_for_model", return_value=None),
patch("hermes_cli.model_switch.get_model_info", return_value=None),
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={"api_key": "***", "base_url": "http://resolved/v1", "api_mode": ""}),
]


async def _run(runner, agent, text, validation):
event = _telegram_event(text)
session_key = runner._session_key_for_source(event.source)
runner._agent_cache[session_key] = (agent, 0.0)
patches = _patched_resolution(validation)
for p in patches:
p.start()
try:
reply = await runner._handle_model_command(event)
finally:
for p in reversed(patches):
p.stop()
return reply, session_key


@pytest.mark.asyncio
async def test_telegram_discord_name_form_switches_provider_and_model(runner_env):
runner, agent, calls = runner_env
reply, session_key = await _run(
runner, agent, "/model name:my-bpx/claude-fable-5-1",
{"accepted": True, "persist": True, "recognized": True, "message": None},
)

assert calls, reply
assert calls[0]["new_provider"] == "my-bpx"
assert calls[0]["new_model"] == "claude-fable-5-1"
override = runner._session_model_overrides[session_key]
assert (override["provider"], override["model"]) == ("my-bpx", "claude-fable-5-1")
# The confirmation names the resolved pair so a mis-split is visible.
assert "`my-bpx/claude-fable-5-1`" in reply, reply
assert "name:" not in reply, reply


@pytest.mark.asyncio
async def test_telegram_unknown_provider_prefix_refused_without_state_change(runner_env):
runner, agent, calls = runner_env
reply, session_key = await _run(
runner, agent, "/model name:no-such-prov/claude-fable-5-1",
{"accepted": True, "persist": True, "recognized": False, "message": "Note: could not verify"},
)

assert "Unknown provider 'no-such-prov'" in reply, reply
assert not calls
assert session_key not in runner._session_model_overrides
Loading
Loading