Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
237807ad3a | ||
|
|
d95c76aa37 | ||
|
|
66a6b9c930 | ||
|
|
e6f7e217ce | ||
|
|
b5d42daa53 | ||
|
|
7ae8aac3b9 | ||
|
|
53bba70854 | ||
|
|
4b2d00f845 | ||
|
|
391b594752 |
@@ -15,6 +15,7 @@ describe('desktop slash command curation', () => {
|
||||
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/version')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/yolo')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ const DESKTOP_COMMAND_META = [
|
||||
['/title', 'Rename the current session'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session'],
|
||||
['/version', 'Show Hermes Agent version'],
|
||||
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
|
||||
] as const
|
||||
|
||||
|
||||
@@ -5109,9 +5109,9 @@ class HermesCLI:
|
||||
resolved_id = self.session_id
|
||||
if resolved_id and resolved_id != self.session_id:
|
||||
ChatConsole().print(
|
||||
f"[{_DIM}]Session {_escape(self.session_id)} was compressed into "
|
||||
f"[dim]Session {_escape(self.session_id)} was compressed into "
|
||||
f"{_escape(resolved_id)}; resuming the descendant with your "
|
||||
f"transcript.[/]"
|
||||
f"transcript.[/dim]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
resolved_meta = self._session_db.get_session(self.session_id)
|
||||
@@ -5391,7 +5391,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -5401,7 +5401,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
return
|
||||
|
||||
# Retarget the terminal/code-exec tools to match the process cwd.
|
||||
@@ -5411,7 +5411,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
|
||||
def _preload_resumed_session(self) -> bool:
|
||||
"""Load a resumed session's history from the DB early (before first chat).
|
||||
@@ -9015,6 +9015,10 @@ class HermesCLI:
|
||||
elif canonical == "update":
|
||||
if self._handle_update_command():
|
||||
return False
|
||||
elif canonical == "version":
|
||||
from hermes_cli.main import _print_version_info
|
||||
|
||||
_print_version_info(check_updates=True)
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "image":
|
||||
|
||||
@@ -7932,6 +7932,8 @@ class GatewayRunner:
|
||||
return await self._handle_profile_command(event)
|
||||
if _cmd_def_inner.name == "update":
|
||||
return await self._handle_update_command(event)
|
||||
if _cmd_def_inner.name == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
# Catch-all: any other recognized slash command reached the
|
||||
# running-agent guard. Reject gracefully rather than falling
|
||||
@@ -8288,6 +8290,9 @@ class GatewayRunner:
|
||||
if canonical == "update":
|
||||
return await self._handle_update_command(event)
|
||||
|
||||
if canonical == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
if canonical == "debug":
|
||||
return await self._handle_debug_command(event)
|
||||
|
||||
@@ -10913,6 +10918,12 @@ class GatewayRunner:
|
||||
return event.platform_update_id <= recorded_uid
|
||||
|
||||
|
||||
async def _handle_version_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /version — show the running Hermes Agent version."""
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
return format_banner_version_label()
|
||||
|
||||
async def _handle_help_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /help command - list available commands."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
|
||||
@@ -216,6 +216,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||||
cli_only=True, args_hint="<path>"),
|
||||
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
|
||||
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
|
||||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
|
||||
|
||||
# Exit
|
||||
@@ -349,6 +350,7 @@ ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
|
||||
"steer",
|
||||
"stop",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+38
-6
@@ -2610,6 +2610,8 @@ def select_provider_and_model(args=None):
|
||||
"api_key": entry.get("api_key", ""),
|
||||
"key_env": entry.get("key_env", ""),
|
||||
"model": entry.get("model", ""),
|
||||
"models": entry.get("models", {}),
|
||||
"discover_models": entry.get("discover_models", True),
|
||||
"api_mode": entry.get("api_mode", ""),
|
||||
"provider_key": provider_key,
|
||||
"api_key_ref": _lookup_ref(
|
||||
@@ -4792,17 +4794,45 @@ def _model_flow_named_custom(config, provider_info):
|
||||
api_key = os.environ.get(key_env, "")
|
||||
config_api_key = _custom_provider_api_key_config_value(provider_info, api_key)
|
||||
|
||||
# Honor ``discover_models: false`` (default True) — when discovery is
|
||||
# disabled, use the configured ``models:`` list verbatim and skip the
|
||||
# live /models probe. This lets operators restrict the picker to the
|
||||
# subset their plan actually serves instead of the endpoint's full
|
||||
# catalog (#18726: Baidu Qianfan returns 100+ models for a 2-3 model
|
||||
# plan). Same semantics as the slash-command picker (model_switch.py
|
||||
# sections 3 & 4): default discovers, false keeps the explicit list.
|
||||
discover = provider_info.get("discover_models", True)
|
||||
if isinstance(discover, str):
|
||||
discover = discover.lower() not in {"false", "no", "0"}
|
||||
configured_models: list[str] = []
|
||||
cfg_models = provider_info.get("models", {})
|
||||
if isinstance(cfg_models, dict):
|
||||
configured_models = [str(m) for m in cfg_models if str(m).strip()]
|
||||
elif isinstance(cfg_models, list):
|
||||
configured_models = [
|
||||
str(m) for m in cfg_models if isinstance(m, str) and m.strip()
|
||||
]
|
||||
|
||||
print(f" Provider: {name}")
|
||||
print(f" URL: {base_url}")
|
||||
if saved_model:
|
||||
print(f" Current: {saved_model}")
|
||||
print()
|
||||
|
||||
print("Fetching available models...")
|
||||
fetch_kwargs = {"timeout": 8.0}
|
||||
if api_mode:
|
||||
fetch_kwargs["api_mode"] = api_mode
|
||||
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
|
||||
if not discover and configured_models:
|
||||
# Discovery disabled with an explicit list — use it verbatim, no probe.
|
||||
print(f"Using configured models (discover_models: false): {len(configured_models)}")
|
||||
models = configured_models
|
||||
else:
|
||||
print("Fetching available models...")
|
||||
fetch_kwargs = {"timeout": 8.0}
|
||||
if api_mode:
|
||||
fetch_kwargs["api_mode"] = api_mode
|
||||
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
|
||||
# If the probe came back empty but the operator configured an explicit
|
||||
# list, fall back to it rather than forcing manual entry.
|
||||
if not models and configured_models:
|
||||
models = configured_models
|
||||
|
||||
if models:
|
||||
default_idx = 0
|
||||
@@ -6617,7 +6647,9 @@ def cmd_import(args):
|
||||
|
||||
|
||||
def _print_version_info(*, check_updates: bool = True) -> None:
|
||||
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
print(format_banner_version_label())
|
||||
print(f"Project: {PROJECT_ROOT}")
|
||||
|
||||
# Show Python version
|
||||
|
||||
@@ -1790,6 +1790,13 @@ def list_authenticated_providers(
|
||||
else (f"env:{key_env}" if key_env else "")
|
||||
)
|
||||
|
||||
# Read discover_models from the entry (same semantics as
|
||||
# section 3: true by default, set false to keep the explicit
|
||||
# ``models:`` list instead of replacing it with live /models).
|
||||
discover = entry.get("discover_models", True)
|
||||
if isinstance(discover, str):
|
||||
discover = discover.lower() not in {"false", "no", "0"}
|
||||
|
||||
group_key = (api_url, credential_identity, api_mode)
|
||||
if group_key not in groups:
|
||||
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
|
||||
@@ -1810,9 +1817,15 @@ def list_authenticated_providers(
|
||||
"api_url": api_url,
|
||||
"api_key": api_key,
|
||||
"models": [],
|
||||
"discover_models": discover,
|
||||
}
|
||||
elif api_key and not groups[group_key].get("api_key"):
|
||||
groups[group_key]["api_key"] = api_key
|
||||
else:
|
||||
if api_key and not groups[group_key].get("api_key"):
|
||||
groups[group_key]["api_key"] = api_key
|
||||
# If any entry in this group opts out of discovery,
|
||||
# honour that for the whole grouped row.
|
||||
if not discover:
|
||||
groups[group_key]["discover_models"] = False
|
||||
|
||||
# The singular ``model:`` field only holds the currently
|
||||
# active model. Hermes's own writer (main.py::_save_custom_provider)
|
||||
@@ -1901,7 +1914,16 @@ def list_authenticated_providers(
|
||||
# - Without an api_key AND no explicit models, fall through to
|
||||
# live discovery so bare-endpoint custom providers (local
|
||||
# llama.cpp / Ollama servers) still appear populated.
|
||||
should_probe = bool(api_url) and (bool(api_key) or not grp["models"])
|
||||
# - When discover_models: false is set, skip live discovery and
|
||||
# keep the explicit ``models:`` list regardless of whether an
|
||||
# api_key is present. This supports endpoints that expose a
|
||||
# full aggregator catalog via /models but only serve a subset
|
||||
# (parity with section 3's user ``providers:`` behaviour).
|
||||
should_probe = (
|
||||
bool(api_url)
|
||||
and (bool(api_key) or not grp["models"])
|
||||
and grp.get("discover_models", True)
|
||||
)
|
||||
if should_probe:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
|
||||
@@ -1448,6 +1448,7 @@ AUTHOR_MAP = {
|
||||
"nicsequenzy@gmail.com": "polnikale", # PR #35717 (discover Playwright headless_shell browser)
|
||||
"wasdhkzk@gmail.com": "whyhkzk", # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)
|
||||
"leonard@sellem.me": "leonardsellem", # PR #37405 (desktop WS origin guard on remote/Tailscale binds)
|
||||
"42903577+ohMyJason@users.noreply.github.com": "ohMyJason", # PR #29810 (discover_models in custom_providers section 4)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -221,3 +221,69 @@ class TestPendingResumeNumberedSelection:
|
||||
|
||||
# A non-resume command disarms the one-shot prompt (#34584).
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
|
||||
class TestRestoreSessionCwdMarkup:
|
||||
"""Regression: _restore_session_cwd must not crash with Rich MarkupError.
|
||||
|
||||
Lines that used ``[{_DIM}]`` inside Rich markup triggered
|
||||
``rich.errors.MarkupError: closing tag [/] at position N has nothing to
|
||||
close`` because ``_DIM`` is an ANSI escape (``\\x1b[2;3m``), not a valid
|
||||
Rich tag. The fix replaces ``[{_DIM}]`` with Rich's native ``[dim]`` tag.
|
||||
See: https://github.com/NousResearch/hermes-agent/issues/39469
|
||||
"""
|
||||
|
||||
def test_missing_dir_does_not_raise_markup_error(self):
|
||||
"""Session cwd gone → dim warning, no MarkupError."""
|
||||
cli_obj = _make_cli()
|
||||
console = MagicMock()
|
||||
cli_obj._output_console = MagicMock(return_value=console)
|
||||
|
||||
# Use a path that definitely does not exist.
|
||||
cli_obj._restore_session_cwd({"cwd": "/nonexistent/path/to/nowhere"})
|
||||
|
||||
# Should have printed a warning via console.print, not crashed.
|
||||
assert console.print.called
|
||||
printed = str(console.print.call_args)
|
||||
assert "Working directory is gone" in printed or "gone" in printed.lower()
|
||||
|
||||
def test_chdir_failure_does_not_raise_markup_error(self, tmp_path):
|
||||
"""os.chdir fails → dim warning, no MarkupError."""
|
||||
import os
|
||||
cli_obj = _make_cli()
|
||||
console = MagicMock()
|
||||
cli_obj._output_console = MagicMock(return_value=console)
|
||||
|
||||
# Create a directory, then make it unreadable (simulate chdir failure).
|
||||
target = tmp_path / "locked"
|
||||
target.mkdir()
|
||||
|
||||
# Patch os.chdir to raise OSError for our target path.
|
||||
original_chdir = os.chdir
|
||||
def fake_chdir(path):
|
||||
if str(path) == str(target):
|
||||
raise OSError("Permission denied")
|
||||
return original_chdir(path)
|
||||
|
||||
with patch("os.chdir", side_effect=fake_chdir):
|
||||
cli_obj._restore_session_cwd({"cwd": str(target)})
|
||||
|
||||
assert console.print.called
|
||||
printed = str(console.print.call_args)
|
||||
assert "Could not enter" in printed or "permission" in printed.lower()
|
||||
|
||||
def test_success_path_does_not_raise_markup_error(self, tmp_path):
|
||||
"""Successful cwd switch → dim info, no MarkupError."""
|
||||
import os
|
||||
cli_obj = _make_cli()
|
||||
console = MagicMock()
|
||||
cli_obj._output_console = MagicMock(return_value=console)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
cli_obj._restore_session_cwd({"cwd": str(tmp_path)})
|
||||
assert console.print.called
|
||||
printed = str(console.print.call_args)
|
||||
assert "Working directory" in printed or "working" in printed.lower()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tests for the /version slash command."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command
|
||||
|
||||
|
||||
def test_version_command_is_registered():
|
||||
cmd = resolve_command("version")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "version"
|
||||
assert cmd.category == "Info"
|
||||
assert resolve_command("v") is cmd
|
||||
|
||||
|
||||
def test_version_is_gateway_known():
|
||||
assert "version" in GATEWAY_KNOWN_COMMANDS
|
||||
assert "v" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
|
||||
def test_process_command_version_prints_version_info():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
|
||||
with patch("hermes_cli.main._print_version_info") as mock_print:
|
||||
assert cli_obj.process_command("/version") is True
|
||||
|
||||
mock_print.assert_called_once_with(check_updates=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for gateway /version command."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
|
||||
def test_gateway_version_command_returns_release_line():
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
result = asyncio.run(GatewayRunner._handle_version_command(None, None)) # type: ignore[arg-type]
|
||||
assert result == format_banner_version_label()
|
||||
@@ -563,3 +563,133 @@ class TestCustomProviderModelSwitch:
|
||||
# clobber it via _preserve_env_ref_templates).
|
||||
assert entry["api_key"] == "${HERMES_CRS_HENKEE_KEY}"
|
||||
assert "cr_live_secret_xyz" not in saved_text
|
||||
|
||||
|
||||
class TestCustomProviderDiscoverModels:
|
||||
"""#18726: honor ``discover_models: false`` in the terminal ``hermes model``
|
||||
named-custom flow so the picker shows the configured ``models:`` subset
|
||||
instead of the endpoint's full live catalog."""
|
||||
|
||||
def test_discover_false_uses_configured_list_and_skips_probe(self, config_home):
|
||||
"""discover_models: false + configured models → no live probe, the
|
||||
configured list is used verbatim."""
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "Baidu Coding",
|
||||
"base_url": "https://qianfan.baidubce.com/v2/coding",
|
||||
"api_key": "sk-test",
|
||||
"discover_models": False,
|
||||
"models": {"kimi-k2.5": {}, "glm-5": {}},
|
||||
"model": "kimi-k2.5",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
|
||||
patch("builtins.input", return_value="2"), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
# The live /models endpoint must NOT be probed when discovery is off.
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
def test_discover_false_saves_choice_from_configured_list(self, config_home):
|
||||
"""User picks the 2nd configured model; it persists, list-driven."""
|
||||
import yaml
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "Baidu Coding",
|
||||
"base_url": "https://qianfan.baidubce.com/v2/coding",
|
||||
"api_key": "sk-test",
|
||||
"discover_models": False,
|
||||
"models": {"kimi-k2.5": {}, "glm-5": {}},
|
||||
"model": "kimi-k2.5",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
|
||||
patch("builtins.input", return_value="2"), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
mock_fetch.assert_not_called()
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model["default"] == "glm-5"
|
||||
|
||||
def test_default_still_probes_when_discover_unset(self, config_home):
|
||||
"""Default (discover_models unset → True) keeps live-probe behaviour
|
||||
even when a models: list is configured — Option B opt-out semantics."""
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "My Gateway",
|
||||
"base_url": "https://gw.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"models": {"subset-a": {}}, # configured, but discovery NOT disabled
|
||||
"model": "subset-a",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"hermes_cli.models.fetch_api_models",
|
||||
return_value=["live-a", "live-b", "live-c"],
|
||||
) as mock_fetch, \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
|
||||
patch("builtins.input", return_value="1"), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
# Probe MUST still run — configured models: alone does not whitelist.
|
||||
mock_fetch.assert_called_once_with(
|
||||
"sk-test",
|
||||
"https://gw.example.com/v1",
|
||||
timeout=8.0,
|
||||
)
|
||||
|
||||
def test_probe_empty_falls_back_to_configured_list(self, config_home):
|
||||
"""When discovery is on but the probe returns nothing, fall back to the
|
||||
configured models: list instead of forcing manual entry."""
|
||||
import yaml
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "My Gateway",
|
||||
"base_url": "https://gw.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"models": {"fallback-a": {}, "fallback-b": {}},
|
||||
"model": "fallback-a",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=[]), \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
|
||||
patch("builtins.input", return_value="2"), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model["default"] == "fallback-b"
|
||||
|
||||
def test_discover_false_string_is_normalised(self, config_home):
|
||||
"""String 'false' (hand-edited configs) disables discovery too."""
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "Baidu Coding",
|
||||
"base_url": "https://qianfan.baidubce.com/v2/coding",
|
||||
"api_key": "sk-test",
|
||||
"discover_models": "false",
|
||||
"models": {"kimi-k2.5": {}, "glm-5": {}},
|
||||
"model": "kimi-k2.5",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
|
||||
patch("builtins.input", return_value="1"), \
|
||||
patch("builtins.print"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
@@ -606,3 +606,107 @@ def test_custom_providers_uses_live_models_for_multi_model_endpoint(monkeypatch)
|
||||
"gateway-model-c",
|
||||
], "Live models must replace the static subset"
|
||||
assert gateway_prov["total_models"] == 3
|
||||
|
||||
|
||||
def test_custom_providers_discover_models_false_keeps_explicit_subset(monkeypatch):
|
||||
"""Custom providers (section 4) with ``discover_models: false`` must keep
|
||||
their explicit ``models:`` subset instead of replacing it with live
|
||||
/models, even when an api_key is present.
|
||||
|
||||
This mirrors section 3 (user ``providers:``) behaviour and supports
|
||||
endpoints that expose a full aggregator catalog via /models but only
|
||||
serve a configured subset.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_fetch_api_models(api_key, base_url):
|
||||
calls.append((api_key, base_url))
|
||||
return ["gateway-model-a", "gateway-model-b", "gateway-model-c"]
|
||||
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
|
||||
|
||||
custom_providers = [
|
||||
{
|
||||
"name": "my-gateway",
|
||||
"api_key": "***",
|
||||
"base_url": "https://gateway.example.com/v1",
|
||||
"discover_models": False,
|
||||
"model": "gateway-model-a",
|
||||
"models": {
|
||||
"gateway-model-a": {"context_length": 128000},
|
||||
"gateway-model-b": {"context_length": 128000},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openrouter",
|
||||
current_base_url="https://openrouter.ai/api/v1",
|
||||
custom_providers=custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
gateway_prov = next(
|
||||
(
|
||||
p
|
||||
for p in providers
|
||||
if p.get("api_url") == "https://gateway.example.com/v1"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert gateway_prov is not None, "Custom provider group not found in results"
|
||||
assert calls == [], (
|
||||
"fetch_api_models must NOT be called when discover_models is false"
|
||||
)
|
||||
assert gateway_prov["models"] == [
|
||||
"gateway-model-a",
|
||||
"gateway-model-b",
|
||||
], "Explicit models: subset must be preserved when discovery is disabled"
|
||||
assert gateway_prov["total_models"] == 2
|
||||
|
||||
|
||||
def test_custom_providers_discover_models_false_string_is_normalised(monkeypatch):
|
||||
"""String ``discover_models: "false"`` (hand-edited / env-style configs)
|
||||
must be treated as a disable, same as the boolean ``False`` and section 3.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_fetch_api_models(api_key, base_url):
|
||||
calls.append((api_key, base_url))
|
||||
return ["live-a", "live-b"]
|
||||
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
|
||||
|
||||
custom_providers = [
|
||||
{
|
||||
"name": "my-gateway",
|
||||
"api_key": "***",
|
||||
"base_url": "https://gateway.example.com/v1",
|
||||
"discover_models": "false",
|
||||
"model": "only-model",
|
||||
"models": {"only-model": {"context_length": 128000}},
|
||||
}
|
||||
]
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openrouter",
|
||||
current_base_url="https://openrouter.ai/api/v1",
|
||||
custom_providers=custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
gateway_prov = next(
|
||||
(p for p in providers if p.get("api_url") == "https://gateway.example.com/v1"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert gateway_prov is not None
|
||||
assert calls == [], "string 'false' must disable live discovery"
|
||||
assert gateway_prov["models"] == ["only-model"]
|
||||
|
||||
@@ -75,18 +75,6 @@ The app checks for updates in the background and offers a one-click update when
|
||||
|
||||
The [manual update process](https://hermes-agent.nousresearch.com/docs/getting-started/updating) also works with the GUI.
|
||||
|
||||
:::caution Remote backends update separately
|
||||
The desktop's one-click update only updates the **app on this machine**. If you've [connected to a remote backend](#connecting-to-a-remote-backend), that backend is a separate Hermes install on the other machine — updating the desktop does **not** update it. After a desktop update, also update the remote so the two stay aligned:
|
||||
|
||||
```bash
|
||||
# on the remote machine, in its Hermes checkout
|
||||
hermes update # or: git pull origin main && uv pip install -e .
|
||||
# then restart the `hermes dashboard` process so the new code loads
|
||||
```
|
||||
|
||||
A desktop that's newer than its backend can misbehave in ways that aren't obvious (e.g. sessions started under one profile landing in another, because newer session-routing the app relies on isn't present in the older backend). The app surfaces a **"Backend out of date"** warning when it detects this skew — if you see it, update the backend as above.
|
||||
:::
|
||||
|
||||
## CLI reference: `hermes desktop`
|
||||
|
||||
To launch via the CLI, simply run `hermes desktop`. By default it installs workspace Node dependencies, builds the current OS's unpacked Electron app, then launches that packaged artifact.
|
||||
@@ -172,7 +160,6 @@ You can also set the backend URL without the UI via the `HERMES_DESKTOP_REMOTE_U
|
||||
- **No "Sign in" button — it asks for a session token instead** — the backend's username/password provider isn't active. `/api/status` won't list `"basic"` in `auth_providers`. Make sure both the username and a password (or password hash) are set in `~/.hermes/.env` and that the dashboard process actually loaded them.
|
||||
- **Signed out on every restart** — set `HERMES_DASHBOARD_BASIC_AUTH_SECRET` to a stable value. Without it the token-signing key is regenerated per boot, invalidating all sessions.
|
||||
- **Connection refused / times out** — the backend bound to `127.0.0.1` (the default) or a firewall/VPN is blocking the port. Bind to `0.0.0.0` or the tailscale IP and open the port to your trusted network.
|
||||
- **New chats land in the wrong profile, or "session not found" after switching profiles** — the remote backend is on **older code** than the desktop. Per-profile session routing is a backend feature; if you updated the desktop but not the remote, the backend ignores the profile the app sends and falls back to its launch profile. Update the remote backend (`hermes update` on that machine, then restart its `hermes dashboard`) so it matches the desktop. See the [Remote backends update separately](#updating) note above; the app also shows a **"Backend out of date"** warning when it detects this.
|
||||
|
||||
For the same setup from the web-dashboard angle, see [Web Dashboard → Connecting Hermes Desktop to a remote backend](./features/web-dashboard.md#connecting-hermes-desktop-to-a-remote-backend); the env vars are catalogued under [Environment Variables → Web Dashboard & Hermes Desktop](../reference/environment-variables.md#web-dashboard--hermes-desktop).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user