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
112 changes: 112 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1733,6 +1733,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:

if canonical == "provider":
return await self._handle_provider_command(event)

if canonical == "models":
return await self._handle_models_command(event)

if canonical == "personality":
return await self._handle_personality_command(event)
Expand Down Expand Up @@ -3018,6 +3021,115 @@ async def _handle_provider_command(self, event: MessageEvent) -> str:
lines.append("Setup: `hermes setup`")
return "\n".join(lines)

async def _handle_models_command(self, event: MessageEvent) -> str:
"""Handle /models [provider|custom:name] — list available models."""
import yaml
from hermes_cli.models import (
normalize_provider,
provider_model_ids,
curated_models_for_provider,
_PROVIDER_LABELS,
fetch_api_models,
)

_TRUNCATE = 50

# ── Resolve current provider + model from config ──────────────────
current_provider = "openrouter"
current_model = ""
config_path = _hermes_home / "config.yaml"
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
current_provider = model_cfg.get("provider", current_provider)
current_model = model_cfg.get("model", "") or ""
except Exception:
pass

current_provider = normalize_provider(current_provider)

# ── Parse requested provider from args ────────────────────────────
arg = event.get_command_args().strip()
if arg:
requested = arg.lower()
else:
requested = current_provider

# ── Named custom provider: custom:lmstudio ────────────────────────
if requested.startswith("custom:"):
custom_name = requested[len("custom:"):].strip()
models: list[str] = []
base_url = ""
api_key = ""
try:
def _norm(s: str) -> str:
return s.strip().lower().replace(" ", "-")
from hermes_cli.config import load_config as _load_cfg
_cfg = _load_cfg()
for entry in (_cfg.get("custom_providers") or []):
if not isinstance(entry, dict):
continue
ename = _norm(str(entry.get("name", "")))
if ename == _norm(custom_name):
base_url = str(entry.get("base_url", "")).strip()
api_key = str(entry.get("api_key", "") or "").strip()
break
except Exception:
pass

if not base_url:
return f"No custom provider named `{custom_name}` found in config.\nCheck `~/.hermes/config.yaml` → `custom_providers`."

live = fetch_api_models(api_key, base_url)
if live:
models = live
if not models:
return f"Could not fetch models from `{base_url}/models`. The endpoint may not support model listing."

provider_label = f"custom:{custom_name}"
lines = [f"🤖 **Models for {provider_label}** ({len(models)} total)\n"]
shown = models[:_TRUNCATE]
for m in shown:
marker = " ← active" if m == current_model else ""
lines.append(f"`{m}`{marker}")
if len(models) > _TRUNCATE:
lines.append(f"\n_…and {len(models) - _TRUNCATE} more. Use `/model {provider_label}:<model-id>` to switch._")
else:
lines.append(f"\nUse `/model {provider_label}:<model-id>` to switch.")
return "\n".join(lines)

# ── Named provider ─────────────────────────────────────────────────
normalized = normalize_provider(requested)
provider_label = _PROVIDER_LABELS.get(normalized, normalized)
models = provider_model_ids(normalized)
if not models:
pairs = curated_models_for_provider(normalized)
models = [m for m, _ in pairs]

if not models:
return (
f"No model list available for `{normalized}`.\n"
"The provider may not support model listing or may not be configured."
)

total = len(models)
shown = models[:_TRUNCATE]
is_active_provider = (normalized == current_provider)

lines = [f"🤖 **Models for {provider_label}** ({total} total)\n"]
for m in shown:
marker = " ← active" if (is_active_provider and m == current_model) else ""
lines.append(f"`{m}`{marker}")

if total > _TRUNCATE:
lines.append(f"\n_…and {total - _TRUNCATE} more._")

lines.append(f"\nUse `/model {normalized}:<model-id>` to switch.")
return "\n".join(lines)

async def _handle_personality_command(self, event: MessageEvent) -> str:
"""Handle /personality command - list or set a personality."""
import yaml
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ class CommandDef:
cli_only=True),
CommandDef("provider", "Show available providers and current provider",
"Configuration"),
CommandDef("models", "List available models for current or specified provider",
"Configuration", args_hint="[provider|custom:name]"),
CommandDef("prompt", "View/set custom system prompt", "Configuration",
cli_only=True, args_hint="[text]", subcommands=("clear",)),
CommandDef("personality", "Set a predefined personality", "Configuration",
Expand Down
167 changes: 167 additions & 0 deletions tests/gateway/test_models_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Tests for the /models slash command."""

import asyncio
from unittest.mock import MagicMock, AsyncMock, patch

import pytest
import yaml

import gateway.run as gateway_run
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource


def _make_event(text="/models"):
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="testuser",
)
return MessageEvent(text=text, source=source)


def _make_runner():
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {}
runner._ephemeral_system_prompt = ""
runner._prefill_messages = []
runner._reasoning_config = None
runner._show_reasoning = False
runner._provider_routing = {}
runner._fallback_model = None
runner._running_agents = {}
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.hooks.loaded_hooks = []
runner._session_db = None
return runner


def _write_config(path, provider="openai", model="gpt-4o"):
path.write_text(
yaml.dump({"model": {"provider": provider, "model": model}}),
encoding="utf-8",
)


class TestModelsCommand:

def test_lists_models_for_current_provider(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml", provider="openai", model="gpt-4o")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

with patch("hermes_cli.models.provider_model_ids", return_value=["gpt-4o", "gpt-4-turbo"]):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models")))

assert "gpt-4o" in result
assert "gpt-4-turbo" in result

def test_marks_active_model(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml", provider="openai", model="gpt-4o")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

with patch("hermes_cli.models.provider_model_ids", return_value=["gpt-4o", "gpt-4-turbo"]):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models")))

assert "← active" in result
assert result.count("← active") == 1
assert "`gpt-4o` ← active" in result

def test_explicit_provider_arg(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml", provider="openai", model="gpt-4o")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

with patch("hermes_cli.models.provider_model_ids", return_value=["claude-opus-4-5", "claude-sonnet-4-5"]):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models anthropic")))

assert "claude-opus-4-5" in result
assert "claude-sonnet-4-5" in result
# Active model belongs to openai, not anthropic — no marker expected
assert "← active" not in result

def test_truncates_long_list(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml", provider="openrouter", model="openai/gpt-4o")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

models = [f"model-{i}" for i in range(80)]
with patch("hermes_cli.models.provider_model_ids", return_value=models):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models openrouter")))

assert "model-0" in result
assert "model-49" in result
assert "model-50" not in result
assert "30 more" in result

def test_no_truncation_when_under_limit(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml", provider="anthropic", model="claude-sonnet-4-5")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

models = [f"claude-model-{i}" for i in range(5)]
with patch("hermes_cli.models.provider_model_ids", return_value=models):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models anthropic")))

assert "more" not in result
for m in models:
assert m in result

def test_unknown_provider_returns_error(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

with patch("hermes_cli.models.provider_model_ids", return_value=[]), \
patch("hermes_cli.models.curated_models_for_provider", return_value=[]):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models nonexistentprovider")))

assert "No model list available" in result

def test_custom_named_provider(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

custom_cfg = {
"model": {"provider": "openai", "model": "gpt-4o"},
"custom_providers": [
{"name": "lmstudio", "base_url": "http://localhost:1234/v1", "api_key": ""}
],
}
with patch("hermes_cli.config.load_config", return_value=custom_cfg), \
patch("hermes_cli.models.fetch_api_models", return_value=["qwen2.5-7b", "llama-3.2-3b"]):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models custom:lmstudio")))

assert "qwen2.5-7b" in result
assert "llama-3.2-3b" in result
assert "lmstudio" in result

def test_custom_named_provider_not_found(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

custom_cfg = {"model": {}, "custom_providers": []}
with patch("hermes_cli.config.load_config", return_value=custom_cfg):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models custom:unknown")))

assert "No custom provider named" in result
assert "unknown" in result

def test_custom_provider_endpoint_unreachable(self, tmp_path, monkeypatch):
_write_config(tmp_path / "config.yaml")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

custom_cfg = {
"model": {},
"custom_providers": [
{"name": "lmstudio", "base_url": "http://localhost:1234/v1", "api_key": ""}
],
}
with patch("hermes_cli.config.load_config", return_value=custom_cfg), \
patch("hermes_cli.models.fetch_api_models", return_value=None):
result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models custom:lmstudio")))

assert "Could not fetch" in result

def test_models_is_dispatched_in_handle_message(self):
import inspect
source = inspect.getsource(gateway_run.GatewayRunner._handle_message)
assert '"models"' in source
Loading