fix(models): preserve @nous: prefix in settings + fix cross-namespace 404 for Nous (#895 #894) (#901)

* fix(models): preserve @nous: prefix in settings + fix cross-namespace 404 for Nous (#895 #894)

* fix(review): persist bare form for CLI compatibility + picker smart-match

The PR persisted `@nous:anthropic/claude-opus-4.6` verbatim to config.yaml
to make the Settings picker match its dropdown options (which carry the
`@nous:` prefix after #885). That fixes the WebUI picker but introduces a
cross-tool regression: hermes-agent's CLI reads `config.yaml -> model.default`
directly and passes it to the provider API verbatim. For aggregator providers
(Nous is one — see hermes_cli/model_normalize.py `_AGGREGATOR_PROVIDERS`),
`normalize_model_for_provider` is skipped entirely (run_agent.py:887), so
the literal `@nous:anthropic/...` string flows to the Nous API, which rejects
it — breaking every user who runs `hermes` in the terminal right after
saving via WebUI.

Fix the tension at the picker rather than the persistence: the existing
`_findModelInDropdown()` smart matcher already normalises both sides
(lowercase, strip namespace prefix, dashes→dots) so a saved bare
`anthropic/claude-opus-4.6` resolves to the `@nous:anthropic/claude-opus-4.6`
option automatically. Applied this in panels.js via `_applyModelToDropdown()`.

Changes:
  api/config.py         revert the @-prefix preservation; persist the
                        resolved bare/slash form (CLI-compatible)
  static/panels.js      Settings picker uses _applyModelToDropdown()
                        instead of raw `.value =` so saved bare forms
                        still select the matching @nous: option
  tests                 test renamed + asserts bare persisted form;
                        new test locks the smart-matcher contract

This also improves behaviour for a dormant case not flagged in #895: a user
who set their default via `hermes model X` and opens Settings for the first
time used to see a blank picker (bare form vs prefixed options). Now the
smart matcher finds the right option, so the "open Settings → save → bare
form in config.yaml" round-trip is stable for both CLI- and WebUI-origin
saves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update CHANGELOG v0.50.171 — bare-form persistence + picker smart-match

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nesquena-hermes
2026-04-23 10:44:10 -07:00
committed by GitHub
parent 498156a3e8
commit 4089972b09
5 changed files with 223 additions and 24 deletions

View File

@@ -29,6 +29,12 @@
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
## [v0.50.171] — 2026-04-23
### Fixed
- **Nous default model picker shows correct selection and saves no longer freeze** — two bugs for Nous/portal provider users: (1) Settings → Preferences → Default Model picker showed blank after saving because `set_hermes_default_model()` wrote a bare resolved form that didn't match the `@nous:...` option values in the dropdown; fixed by using `_applyModelToDropdown()`'s smart normalising matcher to find the right option without requiring an exact string match. (2) Every Settings save triggered a blocking live-fetch from the provider API (~5 s freeze) because `set_hermes_default_model()` called `get_available_models()` before returning; the function now returns a lightweight `{ok, model}` ack and invalidates the TTL cache instead. Config.yaml always stores the CLI-compatible bare/slash form (e.g. `anthropic/claude-opus-4.6`) so CLI users on the same install are unaffected. (`api/config.py`, `static/panels.js`) Closes #895.
- **Cross-namespace models (minimax/, qwen/) no longer 404 for Nous users** — `resolve_model_provider()` checked the `config_base_url` branch before the portal-provider guard. Nous always has a `base_url` in config, so known cross-namespace prefixes were stripped before reaching the portal check. Portal providers are now checked first so all slash-prefixed model IDs reach Nous intact. (`api/config.py`) Closes #894.
## [v0.50.170] — 2026-04-23
### Fixed

View File

@@ -851,6 +851,14 @@ def resolve_model_provider(model_id: str) -> tuple:
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
if config_provider and prefix == config_provider:
return bare, config_provider, config_base_url
# Portal providers (Nous, OpenCode) serve models from multiple upstream
# namespaces — check them BEFORE the config_base_url branch so that a
# Nous user whose config.yaml also has a base_url doesn't accidentally
# fall into the prefix-stripping path (#894: minimax/minimax-m2.7 → bare
# name sent to Nous → 404 because Nous requires the full namespace path).
_PORTAL_PROVIDERS = {"nous", "opencode-zen", "opencode-go"}
if config_provider in _PORTAL_PROVIDERS:
return model_id, config_provider, config_base_url
# If a custom endpoint base_url is configured, don't reroute through OpenRouter
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
# The user has explicitly pointed at a base_url, so trust their routing config.
@@ -863,20 +871,6 @@ def resolve_model_provider(model_id: str) -> tuple:
return bare, config_provider, config_base_url
# Unknown prefix (not a named provider) — pass full model_id through.
return model_id, config_provider, config_base_url
# Portal providers (Nous, OpenCode) serve models from multiple upstream
# namespaces. When the active provider is a portal, trust it for cross-
# namespace models rather than rerouting to OpenRouter — the portal
# handles the upstream routing itself. Preserve the full slash-prefixed
# model ID: portals use the provider/model path as the canonical name
# at their /chat/completions endpoint (e.g. Nous rejects a bare
# "claude-opus-4.6" — it needs "anthropic/claude-opus-4.6" to route
# upstream). This keeps the static-dropdown path consistent with the
# live-fetched path (`@nous:anthropic/claude-opus-4.6`), which also
# preserves the slash via the split-at-first-colon in the @-prefix
# branch above.
_PORTAL_PROVIDERS = {"nous", "opencode-zen", "opencode-go"}
if config_provider in _PORTAL_PROVIDERS:
return model_id, config_provider, config_base_url
# If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
@@ -1021,6 +1015,15 @@ def set_hermes_default_model(model_id: str) -> dict:
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(
selected_model
)
# Persist the resolved bare/slash form, NOT the `@provider:` prefix. The
# prefix is a WebUI-internal routing hint that the hermes-agent CLI does
# not understand — if we wrote `@nous:anthropic/claude-opus-4.6` to
# config.yaml, a user who ran `hermes` in the terminal right after
# saving via WebUI would have the agent send that literal string to the
# Nous API, which would reject it (Nous expects `anthropic/claude-opus-4.6`,
# not the prefixed form). The Settings picker handles the resulting
# CLI-shaped bare form via `_applyModelToDropdown()`'s normalising
# matcher — see `static/panels.js` (#895).
persisted_model = str(resolved_model or selected_model).strip()
persisted_provider = str(resolved_provider or previous_provider or "").strip()
@@ -1040,11 +1043,12 @@ def set_hermes_default_model(model_id: str) -> dict:
_save_yaml_config_file(config_path, config_data)
# Reload outside the lock — reload_config() acquires _cfg_lock itself.
reload_config()
# reload_config() resyncs _cfg_mtime to the new file mtime, so the mtime
# check inside get_available_models() won't trigger invalidation. Drop
# the TTL cache explicitly so the next call recomputes with the new model.
# Invalidate the TTL cache so the next /api/models call returns fresh data
# with the new default model. Do NOT call get_available_models() here —
# it triggers a live provider fetch (up to 8s) that blocks the HTTP response
# to the browser, causing a visible freeze on every Settings save (#895).
invalidate_models_cache()
return get_available_models()
return {"ok": True, "model": persisted_model}
# ── TTL cache for get_available_models() ─────────────────────────────────────

View File

@@ -1456,7 +1456,16 @@ async function loadSettingsPanel(){
}
}catch(e){}
_settingsHermesDefaultModelOnOpen=(models&&models.default_model)||'';
modelSel.value=_settingsHermesDefaultModelOnOpen;
// Use the smart matcher so a saved bare form like "anthropic/claude-opus-4.6"
// (what the CLI's `hermes model` command writes) still selects the matching
// `@nous:anthropic/claude-opus-4.6` option on a Nous setup. Without this, the
// picker renders blank for any user whose default was persisted without the
// @-prefix — CLI-first users, legacy installs, etc.
if(typeof _applyModelToDropdown==='function'){
_applyModelToDropdown(_settingsHermesDefaultModelOnOpen, modelSel);
}else{
modelSel.value=_settingsHermesDefaultModelOnOpen;
}
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
}
// Send key preference

View File

@@ -0,0 +1,173 @@
"""
Regression tests for #895 (set_hermes_default_model strips @nous: prefix + blocks on live fetch)
and #894 (resolve_model_provider strips cross-namespace prefix for portal providers with base_url).
"""
import threading
import pytest
from pathlib import Path
import api.config as config
from api.config import resolve_model_provider, set_hermes_default_model
# ── Shared fixture ──────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
old_cache = config._available_models_cache
old_cache_ts = config._available_models_cache_ts
config_file = tmp_path / "config.yaml"
config_file.write_text(
"model:\n provider: nous\n base_url: https://router.nous.ai/v1\n default: anthropic/claude-opus-4.6\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: Path(str(config_file)))
config.cfg.clear()
config.cfg.update({
"model": {
"provider": "nous",
"base_url": "https://router.nous.ai/v1",
"default": "anthropic/claude-opus-4.6",
}
})
try:
config._cfg_mtime = config_file.stat().st_mtime
except OSError:
config._cfg_mtime = 0.0
config.invalidate_models_cache()
yield
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config._available_models_cache = old_cache
config._available_models_cache_ts = old_cache_ts
# ── #894: portal-provider + config_base_url prefix-stripping ───────────────
class TestResolveModelProviderPortalPriority:
def test_minimax_prefix_preserved_for_nous(self):
"""Nous with base_url must NOT strip minimax/ prefix (#894)."""
m, p, _ = resolve_model_provider("minimax/minimax-m2.7")
assert m == "minimax/minimax-m2.7", f"prefix was stripped: {m!r}"
assert p == "nous"
def test_qwen_prefix_preserved_for_nous(self):
"""Nous with base_url must NOT strip qwen/ prefix (#894)."""
m, p, _ = resolve_model_provider("qwen/qwen3.5-35b-a3b")
assert m == "qwen/qwen3.5-35b-a3b", f"prefix was stripped: {m!r}"
assert p == "nous"
def test_anthropic_prefix_preserved_for_nous(self):
"""Core case: anthropic/claude-opus-4.6 must route to nous intact."""
m, p, _ = resolve_model_provider("anthropic/claude-opus-4.6")
assert m == "anthropic/claude-opus-4.6"
assert p == "nous"
def test_at_nous_prefix_unpacked_correctly(self):
"""@nous:anthropic/claude-opus-4.6 should unpack to bare model and nous provider."""
m, p, _ = resolve_model_provider("@nous:anthropic/claude-opus-4.6")
assert m == "anthropic/claude-opus-4.6"
assert p == "nous"
def test_unknown_prefix_preserved_for_nous(self):
"""Non-PROVIDER_MODELS prefix like moonshotai/ must also pass through intact."""
m, p, _ = resolve_model_provider("moonshotai/kimi-k2.6")
assert m == "moonshotai/kimi-k2.6"
assert p == "nous"
# ── #895: set_hermes_default_model persists @provider: prefix ──────────────
class TestSetDefaultModelPreservesAtPrefix:
def test_at_nous_prefix_strips_to_bare_for_cli_compatibility(self, tmp_path, monkeypatch):
"""set_hermes_default_model must persist the RESOLVED bare/slash form, not the
`@provider:` prefix. The `@provider:` syntax is a WebUI-internal routing hint;
the hermes-agent CLI reads `config.yaml -> model.default` directly and passes
it to the provider API verbatim (see run_agent.py:887 — aggregator providers
like Nous skip normalize_model_for_provider, so the raw string flows through).
Storing `@nous:anthropic/...` would break any user who runs `hermes` in the
terminal right after saving via WebUI — the CLI would send the literal
prefixed string to Nous and hit a 404. The Settings picker handles the bare
form via the smart matcher in `_applyModelToDropdown()`.
"""
import yaml
config_file = tmp_path / "config.yaml"
config_file.write_text(
"model:\n provider: nous\n base_url: https://router.nous.ai/v1\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: Path(str(config_file)))
config.cfg["model"] = {"provider": "nous", "base_url": "https://router.nous.ai/v1"}
try:
config._cfg_mtime = config_file.stat().st_mtime
except OSError:
config._cfg_mtime = 0.0
result = set_hermes_default_model("@nous:anthropic/claude-opus-4.6")
# Result ack echoes the resolved bare/slash form (CLI-compatible)
assert result.get("ok") is True
assert result.get("model") == "anthropic/claude-opus-4.6", (
f"result.model should echo the CLI-compatible resolved form, not the "
f"WebUI-internal @-prefix: {result.get('model')!r}"
)
saved = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert saved["model"]["default"] == "anthropic/claude-opus-4.6", (
f"Config must persist the resolved bare form so the hermes-agent CLI "
f"can read it and pass it to the provider API: "
f"{saved['model']['default']!r}"
)
def test_settings_picker_applies_saved_default_via_smart_matcher(self):
"""The Settings picker must use `_applyModelToDropdown()` (smart matcher),
not raw `modelSel.value = ...`, when initialising from the saved default.
Raw `.value =` silently fails if no option matches exactly — blank picker
on reopen for any saved default whose canonical form doesn't equal an option
value (e.g. CLI-saved `anthropic/claude-opus-4.6` vs Nous dropdown option
`@nous:anthropic/claude-opus-4.6`). `_applyModelToDropdown()` normalises
on both sides and picks the matching option.
"""
js = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text()
# Find the block that sets _settingsHermesDefaultModelOnOpen
anchor = "_settingsHermesDefaultModelOnOpen=(models&&models.default_model)||"
idx = js.find(anchor)
assert idx != -1, "Settings default-model initialisation not found in panels.js"
block = js[idx:idx + 1200]
assert "_applyModelToDropdown" in block, (
"Settings picker must use _applyModelToDropdown() so a saved bare form "
"(e.g. anthropic/claude-opus-4.6) still selects the matching "
"@nous:anthropic/claude-opus-4.6 option. A raw .value assignment leaves "
"the picker blank when the saved ID doesn't match an option verbatim."
)
def test_save_does_not_return_full_model_catalog(self, tmp_path, monkeypatch):
"""set_hermes_default_model must return a lightweight ack, not call get_available_models (#895)."""
config_file = tmp_path / "config.yaml"
config_file.write_text(
"model:\n provider: openrouter\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: Path(str(config_file)))
config.cfg["model"] = {"provider": "openrouter"}
try:
config._cfg_mtime = config_file.stat().st_mtime
except OSError:
config._cfg_mtime = 0.0
result = set_hermes_default_model("openai/gpt-5.4-mini")
# Must be a simple dict with ok+model, NOT the full catalog (which has "groups")
assert result.get("ok") is True
assert "groups" not in result, (
"set_hermes_default_model must not return the full model catalog — "
"doing so triggers a live provider fetch that blocks the HTTP response"
)

View File

@@ -39,17 +39,24 @@ def test_settings_get_returns_defaults():
assert 'default_workspace' in d
def test_default_model_updates_hermes_config():
"""POST /api/default-model updates the effective Hermes default model."""
"""POST /api/default-model updates the effective Hermes default model.
As of #895 the endpoint returns a lightweight ack {ok, model} rather than
the full model catalog, to avoid triggering a blocking live-provider fetch
on every Settings save. The default model is verified via /api/settings.
"""
try:
d, status = post("/api/default-model", {"model": "anthropic/claude-sonnet-4.6"})
assert status == 200
assert 'claude-sonnet-4.6' in d['default_model']
# Lightweight ack — no longer the full catalog
assert d.get("ok") is True, f"expected ok=True, got {d}"
assert 'claude-sonnet-4.6' in d.get("model", ""), (
f"response model field should echo the saved model: {d}"
)
# Verify the setting actually persisted
d2, _ = get("/api/settings")
# Both should resolve to the same model (may differ in prefix normalization)
assert 'claude-sonnet-4.6' in d2['default_model']
finally:
# Always restore to the conftest-injected default so later tests see
# a consistent baseline regardless of test ordering.
post("/api/default-model", {"model": TEST_DEFAULT_MODEL})