Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 237807ad3a Include git SHA in /version output via banner label helper.
Reuses format_banner_version_label() so CLI, TUI, gateway, and desktop show upstream/local commit when available.
2026-06-05 19:39:58 -05:00
Brooklyn Nicholson d95c76aa37 Add /version slash command across CLI, gateway, TUI, and desktop.
Surfaces Hermes Agent version info on demand without leaving chat; works mid-run like /help and /update.
2026-06-05 19:38:32 -05:00
kshitij 66a6b9c930 Merge pull request #39482 from liuhao1024/fix/rich-markup-error-on-session-resume
fix(cli): use Rich [dim] tag instead of ANSI escape in session resume messages
2026-06-05 13:12:17 -07:00
kshitij e6f7e217ce Merge pull request #40093 from kshitijk4poor/feat/named-custom-discover-models-18726
feat(model): honor discover_models in terminal hermes model named-custom flow (closes #18726)
2026-06-05 13:08:33 -07:00
kshitij b5d42daa53 Merge pull request #40080 from kshitijk4poor/salvage/discover-models-section4-29810
feat(model_switch): honor discover_models in custom_providers section 4 (salvage #29810)
2026-06-05 13:05:34 -07:00
kshitijk4poor 7ae8aac3b9 feat(model): honor discover_models in terminal hermes model named-custom flow
The terminal `hermes model` wizard (_model_flow_named_custom) always
live-probed a custom provider's /models endpoint, ignoring the configured
`models:` list. For plans whose endpoint exposes a large catalog (e.g. Baidu
Qianfan Coding Plan returns 100+ models for a 2-3 model plan) the picker
flooded with models the user can't use.

This wires `discover_models` (and the `models:` list) through
_named_custom_provider_map into the flow and honors `discover_models: false`
the same way the slash-command picker (model_switch.py sections 3 & 4) does:
- Default stays True — live probe, no behaviour change.
- discover_models: false → use the configured `models:` list verbatim,
  skip the probe (string 'false'/'no'/'0' normalised to False).
- If the probe is on but returns empty, fall back to the configured list
  instead of forcing manual entry.

Closes #18726
2026-06-06 01:29:41 +05:30
kshitijk4poor 53bba70854 chore: add ohMyJason to AUTHOR_MAP 2026-06-06 01:04:25 +05:30
ohMyJason 4b2d00f845 feat(model_switch): honor discover_models in custom_providers section 4
Section 3 (user `providers:`) already honors `discover_models: false` to
skip live /models discovery and keep the explicit `models:` list. Section 4
(`custom_providers:` list) did not — `should_probe` ignored the field, so any
grouped custom provider with an api_key always had its configured subset
replaced by the full live /models catalog.

This adds the same `discover_models` support to section 4:
- Default True — no behaviour change for existing configs.
- `discover_models: false` keeps the explicit `models:` list even when an
  api_key is present.
- String values ("false"/"no"/"0") are normalised to False, matching
  section 3.
- If any entry in a grouped endpoint opts out, the whole group opts out.

Use case: endpoints that expose a full aggregator catalog via /models but
only serve a configured subset.

Salvaged from #29810 — rebased onto current main. The PR's other change
(`key_env` resolution in section 4) landed independently in commit aa283d1e4
(custom provider picker credential isolation), so only the discover_models
portion is carried here.

Co-authored-by: ohMyJason <42903577+ohMyJason@users.noreply.github.com>
2026-06-06 01:04:13 +05:30
liuhao1024 391b594752 fix(cli): use Rich [dim] tag instead of ANSI escape in _restore_session_cwd
Replace [{_DIM}] with [dim] in all _restore_session_cwd and
_preload_resumed_session messages that go through _console_print (Rich
Console.print).  _DIM is an ANSI escape (\x1b[2;3m) that Rich cannot
parse as a markup tag, causing MarkupError on session resume when the
stored cwd is missing or inaccessible.

Also uses [/dim] closing tag for explicit tag matching.

Fixes #39469
2026-06-05 10:00:21 +08:00
14 changed files with 428 additions and 27 deletions
@@ -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
+9 -5
View File
@@ -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":
+11
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
+25 -3
View File
@@ -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
+1
View File
@@ -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)
}
+66
View File
@@ -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)
+28
View File
@@ -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)
+12
View File
@@ -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"]
-13
View File
@@ -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).