Commit Graph

186 Commits

Author SHA1 Message Date
nesquena-hermes 97b283c5a4 Merge PR #2039 into stage-335 2026-05-11 00:25:07 +00:00
ai-ag2026 2ead7daa2f fix: expose active run lifecycle in health 2026-05-11 02:15:00 +02:00
Frank Song 128e734df4 Fix Xiaomi API key env detection 2026-05-11 07:33:52 +08:00
nesquena-hermes 8824f3c88d Stage 333: PR #2022 — fix(resolver): prefer active provider for default model overlap by @Michaelyklam 2026-05-10 18:16:59 +00:00
Michael Lam ed183784d4 fix: prefer active provider for default model overlap 2026-05-10 10:49:12 -07:00
vikarag 84a172b572 feat: add Xiaomi MiMo provider support
Add xiaomi to _PROVIDER_DISPLAY, _PROVIDER_MODELS, and _PROVIDER_ALIASES
so the WebUI recognizes Xiaomi as a first-class provider.

Models included:
- mimo-v2.5-pro (MiMo V2.5 Pro)
- mimo-v2.5 (MiMo V2.5)
- mimo-v2-pro (MiMo V2 Pro)
- mimo-v2-omni (MiMo V2 Omni)
- mimo-v2-flash (MiMo V2 Flash)

Aliases: mimo, xiaomi-mimo -> xiaomi

The hermes-agent CLI already registers xiaomi as a provider
(hermes_cli/models.py, hermes_cli/auth.py) but the WebUI was missing
the corresponding entries, causing the model dropdown to fall back to
OpenRouter and the provider list to show 'Unsupported'.
2026-05-10 17:48:37 +09:00
nesquena-hermes 4751b5ace5 Stage 326: PR #1951 — fix: only evaluate goal hook on goal-related turns (#1932) by @amlyczz 2026-05-09 18:17:20 +00:00
nesquena-hermes 072ec41e0a Stage 326: PR #1947 — fix: show same model from different custom providers instead of deduplicating by @happy5318 2026-05-09 18:16:16 +00:00
happy5318 a6599cd68e fix: show same model from different custom providers instead of deduplicating
When multiple custom providers expose the same model ID (e.g. baidu,
huoshan, and liantong all offering glm-5.1), only the first provider's
entry was shown in the model dropdown.

Root cause (backend):  used the bare model ID as the
dedup key, so the second and subsequent providers with the same model
were silently skipped.

Root cause (frontend):  stripped the @provider: prefix before
comparing, so @custom:baidu:glm-5.1 and @custom:huoshan:glm-5.1 were
treated as duplicates.

Fix:
- Backend: change _seen_custom_ids key to '{slug}:{model_id}' so each
  provider's models are tracked independently.
- Frontend: add _providerOf() helper and deduplicate on the composite
  (normId, provider) key instead of normId alone. Bare model IDs
  (without @provider: prefix) still deduplicate on normId for backward
  compatibility.
2026-05-09 16:17:23 +08:00
liyang1116 7532482393 fix: fix(config): skip #1776 provider peel for custom host:port slugs
model_with_provider_context can emit @custom:<host>:<port>:<model> when
model_provider is derived from an OpenAI base_url authority (e.g.
custom:10.8.0.1:8080). The colon-count heuristic meant for @custom:slug:model:free
mistook those extra colons for an over-split model ID and prepended the port
segment onto the bare model (8080:Qwen3-235B), breaking WebUI while CLI/curl
stayed correct.

Detect endpoint-style slugs (IPv4/localhost/hostname + numeric port) and skip
the peel in that case. Add regression tests for IPv4, dotted hostname,
localhost, and model_with_provider_context round-trip.
2026-05-09 16:16:32 +08:00
zqy 6fd07c2af4 fix: only evaluate goal hook on goal-related turns (#1932)
The goal evaluation hook was firing on every completed assistant turn
when a goal was active, even for unrelated messages like "what time is
it". This burned the goal budget, triggered continuation prompts that
interrupted unrelated conversations, and made /goal status numbers
misleading.

Add STREAM_GOAL_RELATED and PENDING_GOAL_CONTINUATION flags to gate
the evaluate_goal_after_turn() call in the streaming loop. Only streams
started from goal kickoff (/goal <text>) or goal continuation are
marked as goal-related. Normal user messages skip the hook entirely.
2026-05-09 15:08:13 +08:00
nesquena-hermes bec4433c2a Stage 325: PR #1929 — feat: add opt-in session endless scroll by @ai-ag2026
Conflict resolution: both #1928 (session jump buttons) and #1929 (endless
scroll) add their own settings/UI/i18n keys. Resolved by keeping both —
the features are independent opt-in toggles.
2026-05-08 21:23:34 +00:00
ai-ag2026 ea8aca2818 feat: add opt-in session endless scroll 2026-05-08 21:16:21 +00:00
ai-ag2026 df1ba9fde8 feat: add opt-in session jump buttons 2026-05-08 21:16:19 +00:00
王浩生 cdbdc28f5c fix(config): custom named provider API key resolution in WebUI
- add robust custom provider credential/base_url resolver
- apply fallback in streaming and routes agent init/self-heal paths
- support slug normalization and config fallbacks for custom:* providers
2026-05-08 16:40:17 +00:00
nesquena-hermes a11cbd3ee9 Stage 319: PR #1862 — preserve local custom provider model ids by @franksong2702 2026-05-08 15:16:18 +00:00
Frank Song 414c474d97 fix: preserve local custom provider model ids 2026-05-08 15:16:18 +00:00
Sanjay Santhanam a958c29373 fix(config): phantom Custom group when active provider is ai-gateway (#1881)
Two bugs in get_available_models() conspired to duplicate the active
provider's auto-detected models under a phantom 'Custom' group whenever
custom_providers was also declared in config.yaml:

1. custom:* PIDs not in _named_custom_groups (e.g. stale slugs left from
   prior configs) fell through to the auto_detected_models fallback, copying
   the active provider's whole catalog into a phantom Custom: <slug> group.
   Fix: continue unconditionally for ANY custom:* PID — the named-group
   branch is the only legitimate population path.

2. The bare 'custom' PID, with the active provider being concrete (e.g.
   ai-gateway), hit 'elif auto_detected_models: copy.deepcopy(...)' and
   built a duplicate Custom group of the active provider's models with
   mismatched provider prefixes. Fix: when pid == 'custom' and the active
   provider is non-custom, leave models_for_group empty.

The reporter also suggested a third fix gating resolve_model_provider() on
config_provider — that's intentionally NOT applied because it conflicts with
the long-standing model-specific-override semantics covered by
test_model_resolver.py::test_custom_provider_*_routes_to_named_custom_provider
(custom_providers entries explicitly override the active provider's routing
when the user opted-in). The reporter's symptom (duplicate UI group) lives
entirely in get_available_models()'s group construction and is fully fixed
by the two changes above.

Tests: 6 new regression tests (3 in #1881 file + reuse), 774 broader
tests still green (model/provider/custom/config domain).
2026-05-08 15:15:49 +00:00
hermes-agent 1f702c7569 stage-313 absorb: gate _resolve_configured_provider_id alias resolution + harden bootstrap test isolation
Two in-stage fixes for v0.51.19 batch:

1) api/config.py — add resolve_alias=False param to
   _resolve_configured_provider_id() and pass it from
   resolve_model_provider(). The PR #1818 swap from
   _resolve_provider_alias() to _resolve_configured_provider_id()
   was correct for active-provider/badge surfaces but broke #1625's
   local-server-provider literal-preservation contract: 'ollama' →
   'custom' and 'lm-studio' → 'lmstudio' alias-collapse caused
   _LOCAL_SERVER_PROVIDERS membership check to miss, breaking the
   model-id full-path preservation for LM Studio/Ollama. The new
   flag preserves the raw provider value when called from
   resolve_model_provider, and named-custom-slug + base-url
   fallback both still run unchanged.

2) tests/test_bootstrap_discover_agent.py — pin Path.home() in
   _isolate_discover_agent_dir so the hard-coded
   'Path.home() / .hermes / hermes-agent' / 'Path.home() /
   hermes-agent' candidates in discover_agent_dir() can't pick up
   the dev machine's real install. The original PR #1817 isolation
   helper covered HERMES_HOME, HERMES_WEBUI_AGENT_DIR, and
   REPO_ROOT but missed the Path.home() leak.

Both surfaced on full pytest pre-release gate, fixed in stage,
ship in v0.51.19. Tests: full suite green.
2026-05-07 17:07:48 +00:00
Frank Song 3ac89c2696 fix: route named custom provider model selections 2026-05-07 21:40:23 +08:00
Sanjay Santhanam 064d14c85b fix(config): custom provider + :free/:beta/:thinking suffix mis-resolution (#1776)
PR #1762 fixed the rsplit grammar collision for plain @openrouter:model:free
qualifiers, but skipped the fallback whenever the provider hint started with
'custom:' on the assumption that custom providers route directly. That left
'@custom:my-key:some-model:free' broken: rsplit yields
provider='custom:my-key:some-model', bare='free' → custom guard skips the
split-fallback → returns provider='custom:my-key:some-model', model='free'.

Detect the over-split structurally instead of using a known-suffix allowlist:
custom hints carry exactly one segment after 'custom:' (constructed at
api/config.py:1363 as 'custom:' + entry_name). So any rsplit result of
'custom:<a>:<b>' with bare model '<c>' has eaten one model segment — peel
it back with a second rsplit and prepend it to the bare model.

This is robust for :free / :beta / :thinking / :preview / any future
OpenRouter suffix without an allowlist to maintain.

Adds 5 regression tests covering the matrix (free/beta/thinking/preview/
slashed-model). All 7 existing #1744 tests still pass; #1228 tests
unaffected.

Co-authored-by: Cake <51058514+Sanjays2402@users.noreply.github.com>
2026-05-07 06:25:16 +00:00
bergeouss 9711070119 fix: resolve rsplit collision for OpenRouter models with :free/:beta/:thinking suffixes (#1744)
The previous approach of prepending 'openrouter/' to the model ID in the
catalog was incorrect — it only masked the symptom while regressing the
config_provider=openrouter codepath.

The root cause is in resolve_model_provider(): rsplit(':', 1) on
'@openrouter:tencent/hy3-preview:free' yields provider='openrouter:tencent/hy3-preview'
and model='free', because the ':free' suffix collides with the @provider:model
grammar.

Fix: after rsplit, validate that the extracted provider hint is a known
provider (in _PROVIDER_MODELS, _PROVIDER_DISPLAY, or starts with 'custom:').
If not, fall back to split(':', 1) so trailing suffixes stay attached to
the model ID.

This fixes all current and future OR models with colon-suffixed tags
(:free, :beta, :thinking, :nitro, etc.) without catalog changes.

Also adds regression tests for the affected models and edge cases.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-05-07 01:39:51 +00:00
bergeouss ca1a268512 fix: add missing openrouter/ prefix for tencent/hy3-preview:free model (#1744) 2026-05-07 01:39:51 +00:00
Michael Lam 276570faec fix: route custom provider models dict selections 2026-05-06 18:11:12 +00:00
nesquena-hermes 97aa3247e1 fix(test-isolation): in-stage fixes for stage-302 pre-release gate
PR #1728's path/mtime-aware get_config() reload broke the common test
idiom monkeypatch.setattr(config, 'cfg', {...}). The cfg = _cfg_cache
alias bound at import time means the rebinding only changes the module
attribute; _cfg_cache stays unchanged, so _cfg_has_in_memory_overrides()
returned False and the path-aware reload silently overwrote the test's
override. test_issue1426_openrouter_* and test_issue1680_codex_* failed
in the full suite while passing standalone — exact polluter signature.

Fix:
- _cfg_has_in_memory_overrides() now also detects cfg-rebind via
  cfg is not _cfg_cache.
- get_config() returns cfg (the override) when it differs from
  _cfg_cache, so callers see the test's intended override.
- 4 new regression tests pin both prongs in
  test_stage302_config_override_regression.py.

Defense-in-depth (prong 2 of test-isolation-flake-recipe):
- test_sprint3.py::test_skills_list and test_skills_list_has_required_fields
  now skip on empty skills list rather than asserting > 0 / IndexError, so
  future profile-switch / SKILLS_DIR repointing pollutions don't break
  the build. The contract under test is 'API returns a non-empty list
  when there are entries' — empty list signals a polluter elsewhere.

Pre-existing wall-clock flake fix (absorb-in-release):
- test_issue1144_session_time_sync.py::test_relative_time_uses_server_clock
  now pins Date.now() to a fixed instant. Without pinning, when CI runs
  near 08:00 UTC the projected server time crosses midnight and '5 minutes
  ago' silently becomes '1d'. Same time-of-day-pin pattern as the sibling
  test_session_bucket_uses_server_clock used.

Test count: 4580 → 4584 (+4 regression tests). 0 failures, stably green
across multiple runs.
2026-05-06 08:10:08 +00:00
starship-s 74eb55d986 fix(profile): preserve context when starting chats 2026-05-06 06:27:00 +00:00
Michael Lam 63239d5b3c fix(models): delegate generic provider catalogs to Hermes CLI 2026-05-06 06:26:44 +00:00
Nathan Esquenazi a66feb2661 Stage 301: PR #1703 2026-05-05 15:41:43 +00:00
Michael Lam c4ef5b6945 fix: invalidate model cache on auth-store drift 2026-05-05 08:33:44 -07:00
Michael Lam 0fe3927655 fix: surface Codex spark models 2026-05-04 23:10:36 -07:00
test 34b060d993 Stage 296: PR #1648 — session save mode config (closes #1406) by @Michaelyklam 2026-05-04 21:26:52 +00:00
Michael Lam 89099928db fix: make TPS header display optional 2026-05-04 21:26:43 +00:00
Michael Lam 876a670387 feat: add session save mode config 2026-05-04 14:05:49 -07:00
Hermes Agent 9aad249e5a chore(release): stamp v0.50.295 — 3-PR batch + Opus pass
Constituent PRs:
  #1637 by @Michaelyklam — protect raw pre from glued-bold lift (closes #1451)
  #1639 by @bergeouss — macOS auto-scroll race + custom:* provider list (closes #1360, #1619)
  #1642 by @nesquena-hermes — YAML/JSON/diff code block newlines (closes #1618, #1463)

Opus advisor SHIP verdict on stage-295. One observation absorbed:
- api/config.py:2533 dead-code comment per Opus (defensive belt-and-braces
  for #1619 fallback; load-bearing fix is in routes.py /api/models/live)

PR #1641 (Michaelyklam parallel-discovery duplicate of #1642) closed as
superseded; UI media adopted with co-author trailer.

4245 → 4255 tests passing (+10).
2026-05-04 18:37:52 +00:00
bergeouss 324aeaaded fix: macOS auto-scroll momentum race (#1360) + custom:* provider model list (#1619)
#1360 — On macOS WKWebView, trackpad momentum scrolling fires scroll
events that interleave with the _programmaticScroll setTimeout(0) guard.
A mid-momentum scroll event either gets swallowed (_programmaticScroll
still true) or falsely reports nearBottom (momentum hasn't settled),
keeping _scrollPinned=true and snapping the viewport back down.

Fix: rAF-debounce the scroll listener so the nearBottom check runs at
the next paint frame when the browser's scroll position has settled.
Added a hysteresis counter requiring 2 consecutive near-bottom samples
before re-pinning, preventing accidental re-pin during deceleration.

#1619 — When a custom:* provider (e.g. custom:relay via custom_providers)
has models that overlap with auto-detected models from base_url /v1/models,
the dedup logic at config.py:2263 skipped them all. The named custom
group ended up empty, and the continue at line 2334 silently discarded
the auto-detected models. Result: only the default model appeared.

Fix 1 (config.py): When custom:* named group has 0 models after dedup,
fall back to auto_detected_models_by_provider instead of dropping them.

Fix 2 (routes.py): Extended /api/models/live fallback to handle
custom:* slugs (not just bare "custom") for both custom_providers
config lookup and base_url live fetch.
2026-05-04 18:23:04 +00:00
test 6bbf913e22 Stage 294: PR #1631 — streaming stability trio (closes #1623, #1624, #1625) by @nesquena-hermes — APPROVED 2026-05-04 17:13:08 +00:00
nesquena-hermes 66b925f59d fix(cache): stamp /api/models disk cache with WebUI version + schema version (#1633)
Closes #1633. STATE_DIR/models_cache.json was persisted across server
restarts without any version stamp, so a Docker container update from
version A to B read the cache file written by version A — users saw
stale picker contents (missing models, phantom provider groups) for
up to 24 hours until either the TTL expired, an unrelated provider
edit triggered invalidate_models_cache(), or they manually deleted
the file.

Reporter Deor (Discord) updated to v0.50.292 — which contained fixes
for #1538, #1539, and #1568 — did a hard refresh and cleared site
data, and still saw byte-for-byte identical picker contents because
the server kept reading the v0.50.281 cache file off the host-mounted
state volume.

Fix:
  * _save_models_cache_to_disk() stamps payloads with _webui_version
    (resolved lazily from api.updates.WEBUI_VERSION via sys.modules
    lookup to avoid the api.config <-> api.updates circular import)
    and _schema_version = 2.
  * New _is_loadable_disk_cache() validator checks both stamps in
    addition to shape. Mismatch on either field rejects the load.
  * _load_models_cache_from_disk() calls the new validator and
    strips the disk-only metadata before returning, so the rest of
    the code sees the same shape it always did.
  * _is_valid_models_cache() kept loose (shape-only) so in-memory
    cache writes that never touch disk don't fail validation.

Schema version is independent of the WebUI version stamp so future
cache-shape changes can invalidate older releases without relying
on a tag bump alone.

Early-init edge case (api.updates not yet loaded) skips the version
check rather than wedging the boot — at worst an unstamped file is
written once and rejected on the next call.

Updated existing tests/test_model_cache_metadata.py to use subset/
round-trip semantics rather than byte-for-byte equality, since the
disk payload now has additional stamps. The four response-shape
fields still round-trip verbatim; the load result is unchanged
(stamps stripped). 19 new regression tests.

4180 -> 4199 tests pass.
2026-05-04 17:03:02 +00:00
nesquena-hermes 040cb8af70 Apply Opus pre-release SHOULD-FIX + NITs (in-PR per release policy)
SHOULD-FIX: rate-limit _repair_stale_pending repair-firing telemetry. Switch
from unconditional logger.warning to age-keyed: WARNING when pending_age <
5min (the diagnostically valuable race window — actual leak-path candidates
that slipped past the grace guard) and DEBUG for the long-tail (orphaned
sidecars from prior process lifetimes). Prevents reconnect loops on stuck
sessions from flooding the log while preserving the diagnostic signal we
want for tuning _REPAIR_STALE_PENDING_GRACE_SECONDS empirically.

NIT: _LOCAL_SERVER_PROVIDERS expanded with lm-studio (hyphenated alias used
in some custom_providers configs and already recognized at api/config.py:2189
for SSRF host trust) and localai (LocalAI project). Test parametrize expanded
from 7 to 11 names, also covering pre-existing koboldcpp and textgen for
symmetry. +4 regression tests.

NIT (docs): CHANGELOG callout for the RFC1918 behavior change. Internal-
network OpenAI-compatible proxies now preserve the model prefix on private-IP
base_urls. Documented the migration path: configure as a custom_providers
entry to bypass the local-server detection.

NIT (deferred, optional): narrowing the heuristic to is_loopback only is
left as future work; the broader scope was an explicit goal in the bug
body and Opus flagged it as SHOULD-DISCUSS-but-not-block.

4184 -> 4188 passing. 0 regressions. ~10 LOC absorbed total.
2026-05-04 16:50:22 +00:00
nesquena-hermes bea57beba9 fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625)
Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived
handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller).
Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE +
5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the
kernel tear sockets down on flaky networks before the app sent its first
keepalive byte — drops at ~10s during long thinking phases. New named
constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the
inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't
re-introduce the misalignment.

Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger.
Without it, any narrow race between the streaming thread clearing
pending_user_message and STREAMS.pop(stream_id) produces a false-positive
'Previous turn did not complete.' marker on a turn that finished correctly
(reproducible after every command-approval turn). Defense-in-depth, not
the root-cause fix — the actual streaming-thread leak path is tracked
separately. Falsy pending_started_at (legacy sidecars) treated as
'old enough' so legitimate legacy-data recovery still works. Plus
logger.warning telemetry on every legitimate repair so the next batch of
user reports tells us whether the underlying race still fires.

Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM,
TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style
model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New
_LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/
RFC1918 heuristic — either signal triggers no-strip. Backward compat
preserved for OpenAI-compatible proxies on public hosts (LiteLLM at
litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the
existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom
rule for loopback hosts (see api/config.py and test_model_resolver.py
docstring update). Reported by @akarichan8231 in Discord on 2026-05-04.

42 regression tests across:
  tests/test_issue1623_sse_heartbeat_alignment.py (3)
  tests/test_issue1624_repair_stale_pending_grace.py (9)
  tests/test_issue1625_local_server_model_id_preservation.py (30)

4142 -> 4184 passing. 0 regressions.
2026-05-04 16:49:43 +00:00
Hermes Agent 1549a10510 chore(release): stamp v0.50.292 — 12-PR batch + Opus follow-ups absorbed
Constituent PRs:
  #1597 by @Michaelyklam — pytest config-path isolation
  #1598 by @Michaelyklam — multi-tab SSE broadcast (closes #1584)
  #1599 by @Sanjays2402 — _pending_started_at truthy-check (closes #1595)
  #1600 by @Michaelyklam — streaming markdown subpath/fallback
  #1601 by @Michaelyklam — subpath frontend routes
  #1602 by @ai-ag2026 — cross-source continuation
  #1603 by @ai-ag2026 — git remote name preservation
  #1605 by @ai-ag2026 — update banner branch labels
  #1608 by @franksong2702 — cron broad-except removal (closes #1578)
  #1609 by @franksong2702 — server.py socket cleanup (closes #1583)
  #1621 by @franksong2702 — fork indicator polish (fixes #1613)
  #1622 by @s905060 — paste text-with-image (closes #1620)

Opus advisor SHIP verdict + 2 SHOULD-FIX absorbed in-release:
  • #1598 ordering race fixed (offline-buffer replay moved inside lock)
  • #1601 sessions.js:1440 gateway SSE probe baseURI parity fix

4117 → 4142 tests passing.
2026-05-04 15:45:41 +00:00
Michael Lam 6c5bc95b3b fix: broadcast SSE events to all tabs 2026-05-03 22:43:11 -07:00
Hermes Bot c07999f0ce Stage 288: PR #1572 — collapse duplicate provider groups (closes #1568) by @nesquena-hermes — APPROVED 2026-05-03 22:37:43 +00:00
nesquena-hermes 458cf38ac9 fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).

Three structural causes, all in api/config.py:_build_available_models_uncached:

1. **Detection-path id leakage.** The detection block at line ~1980
   reads cfg["providers"] keys verbatim. If the user's config has
   ``providers.opencode_go.api_key`` (underscore variant) AND another
   path adds the canonical ``opencode-go`` (e.g. via active_provider),
   both end up in detected_providers and the build loop creates two
   distinct provider groups with the second labelled via the
   ``pid.title()`` fallback as ``"Opencode_Go"``.

2. **Injection-block rogue model.** The default-model injection block
   at line ~2598 puts ANY ``model.default`` string into the picker as
   a fake option. A stray ``model.default: opencode_go`` (provider id
   mistakenly used as a model id) surfaces as a phantom model
   labelled ``"Opencode GO"``.

3. **Empty-group bleed.** When a non-canonical provider id makes it
   into detected_providers but has no entry in _PROVIDER_MODELS, the
   build loop creates an optgroup with zero models — pure UI noise.

This PR addresses all three:

- **New `_canonicalise_provider_id()` helper** that folds underscores
  to hyphens, lowercases, and applies alias resolution only when the
  alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
  last constraint avoids round-tripping ``x-ai`` (canonical) through
  the alias table to ``xai`` (which the WebUI doesn't index by).

- **Detection-path canonicalisation.** The cfg["providers"] scan
  applies the helper before adding to detected_providers. Same
  treatment in the only_show_configured intersection so that mode
  doesn't accidentally exclude the canonical id when configured_providers
  only contains the underscore-variant key.

- **Post-collection dedup pass** that re-canonicalises every entry in
  detected_providers — belt-and-braces against future regressions in
  any of the ~25 ``detected_providers.add(...)`` callsites without
  auditing each one. Idempotent for already-canonical ids.

- **Provider-id guard on the model.default injection block.** When
  the injected value matches a known provider display name or alias
  (after underscore/case normalisation), skip the injection and emit
  a `logger.warning` instead. Real unknown model ids (newly released
  models, custom endpoints) still get injected — only provider-shaped
  values are rejected.

- **Empty-group filter at end of build.** Drop optgroups with zero
  models. Custom: groups (`provider_id` starts with `custom:`) are
  exempt — users may want an empty card visible as a reminder.

Tests
-----

`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):

- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
  canonical preserved, underscore folded, case folded, aliases
  resolved, x-ai not round-tripped, empty input, unknown ids
  normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
  underscored providers-key produces ONE group not two (Deor's case),
  uppercase providers-key collapsed, aliased keys (z-ai → zai)
  collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
  doesn't inject phantom + WARNING logged; alias as model.default also
  caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
  custom: providers exempted from filter

Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.

Verification
------------

Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):

  /api/models groups: 1 (was 2)
  Browser <select> optgroups: 1 (was 2)
  Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)

Five-scenario sweep all collapse to ONE provider group:

| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group  |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group  |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group  |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom  |
| Happy path (canonical-only) | 1 group | 1 group  |

4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.

Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
nesquena-hermes a2b793be4f fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.

#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.

Two structural causes:

1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
   regardless of authentication state and unconditionally live-fetches
   the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
   providers in ``detected_providers``, gated on
   ``hermes_cli.models.list_available_providers().authenticated``.
   That flag can disagree with ``get_auth_status(<id>).logged_in`` on
   some hermes_cli versions.

When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.

Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.

UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:

- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
  selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
  understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
  header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
  pill list (only fires for non-OAuth providers — OAuth cards never
  render pills) with a tooltip pointing at the slash command

Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:

1. Always include the user's currently-selected model (sticky — no
   orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
   ``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
   (one model per vendor each pass) so no vendor monopolises the slots

Changes by file
===============

api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
  `_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
  `_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
  - Apply featured-set cap with sticky-selection signal
  - Return `extra_models` alongside `models` for the catalog tail
  - Decorate optgroup label with truncation count
  - Drop stale-4 fallback when authenticated but live-fetch empty
    (omit the group entirely; truth lives in the providers card and
    the next cache rebuild will heal it)
  - Keep stale-4 fallback when hermes_cli is unavailable (test envs,
    package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
  check after the existing `list_available_providers()` loop, so the
  picker matches the providers card on hermes_cli versions where the
  two signals disagree

api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
  frontend for the "N models · OAuth" header text)

api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
  enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
  endpoint's logic

static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
  outside the visible dropdown still renders with its proper label

static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
  full catalog (not just the trimmed featured slice)

static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
  `p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
  models_total` with a tooltip pointing at the slash command

static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
  cursor:help, no select) — visually distinct from real model pills

Tests
=====

`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):

- TestBuildNousFeaturedSet (8): unit tests on the helper —
  small-catalog no-op, large-catalog cap to target, disjoint+complete
  invariants, priority-vendor round-robin guarantee, sticky selection
  with and without `@nous:` prefix, curated-flagship preservation,
  empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
  on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
  `get_auth_status` agrees but `list_available_providers` disagrees;
  picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
  hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
  exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
  the JS contract for `extra_models`, `models_total`, and the "+N more"
  disclosure

Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
  15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
  _dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
  slice as expected

4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.

Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
Hermes Bot c73a5eb384 Stage 283: PR #1553 — silent credential self-heal on 401 (#1401) by @bergeouss 2026-05-03 19:19:02 +00:00
Hermes Bot 0750da5b37 fix(models): structural OpenRouter free-tier visibility — live fetch + augment fallback (#1426)
Augments @bergeouss's PR #1548 v2 with the structural fix the issue
actually requested. The original PR added 5 hardcoded entries to
_FALLBACK_MODELS which would rot fast as OpenRouter's free-tier roster
turns over monthly.

Adds proper live-fetch logic to the OpenRouter group population so the
free-tier list stays fresh without requiring a code release every time
a new free model lands.

api/config.py:2120 — replaces the static _FALLBACK_MODELS slice with:

  1. Live curated catalog via hermes_cli.models.fetch_openrouter_models()
     — applies the tool-support filter (Kilo-Org/kilocode#9068).
  2. Free-tier live fetch — direct call to https://openrouter.ai/api/v1/models,
     filtered to free-tier-only (pricing.prompt == 0 AND pricing.completion
     == 0, OR :free suffix), bypasses the tool-support filter so newly-added
     free variants appear even before OpenRouter annotates them with tools.
     Capped at 30 entries to keep the picker usable.
  3. Defense-in-depth fallback to _FALLBACK_MODELS (which retains
     @bergeouss's hardcoded list for offline / test envs).
  4. Deduplication via seen_ids — model in both surfaces appears once.

5 new tests + 1 fixed test in tests/test_minimax_provider.py (scoped the
provider='MiniMax' assertion to direct-MiniMax routes by filtering for
'minimax/' prefix and excluding ':free' since the OpenRouter free-tier
variant minimax/minimax-m2.5:free correctly carries provider='OpenRouter').

Co-authored-by: bergeouss <[email protected]>
2026-05-03 19:18:44 +00:00
bergeouss f60db40133 fix: include OpenRouter free-tier models in fallback list (#1426) 2026-05-03 19:02:13 +00:00
bergeouss 8fe593fa38 feat: silent credential self-heal on 401 errors (#1401) 2026-05-03 18:32:53 +00:00
nesquena-hermes c21e3086a2 docs: align _format_nous_label docstring examples with actual output
Per review observation on PR #1544: the docstring claimed
'Gemini 3.1 Pro Preview' and 'Nemotron 3 Super 120B A12B' but the
helper reuses _format_ollama_label's 3-letter-token rule, which
uppercases 'PRO' (and the existing rule for tokens like 'a12b'
renders 'A12b' not 'A12B'). Update the examples to match actual
behavior — labels are unchanged, only the docstring.

Pure-comment change, no behavioral effect. Test counts unchanged
(4013 passed).
2026-05-03 18:12:01 +00:00
nesquena-hermes bff8cb2b58 fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).

#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.

The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.

Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
  `_format_ollama_label()` token rules, drops the vendor namespace, and
  appends ` (via Nous)` so labels disambiguate from same-named direct-
  provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
  mirroring the Ollama Cloud pattern: live-fetch through
  `hermes_cli.models.provider_model_ids("nous")`, prefix every id with
  `@nous:` (matches the existing routing convention from PR-era #854 and
  pinned in tests/test_nous_portal_routing.py), fall back to the curated
  4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
  separate code path that builds Settings → Providers card models, and
  it had the identical bug shape.

#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:

- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
  the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
  populated by `populateModelDropdown()` on app boot and profile switch.

Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.

Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
  nulls both cache slots, exposed on `window` (typeof-guarded so the
  module remains importable in headless vm contexts — needed by the
  existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
  helper that calls the invalidator + `populateModelDropdown()`, wrapped
  in try/catch so the providers panel update never breaks if a
  downstream module hasn't loaded yet. Both `_saveProviderKey` and
  `_removeProviderKey` invoke it (defense-in-depth: same staleness shape
  applies to the add path too).

Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
  surfaces ≥20 entries, every id starts with `@nous:`, every label ends
  with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
  Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
  works when hermes_cli raises, label formatter unit tests (vendor
  namespace stripping, variant rendering, MiniMax mixed-case), the
  curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
  (11 tests): invalidator helper exists and clears both cache slots,
  exposed on window with typeof guard, both save and remove paths
  invoke the dropdown flush, helper calls both invalidator and
  populateModelDropdown, helper is resilient to missing modules,
  helper does not block panel refresh, server-side
  `set_provider_key → invalidate_models_cache` invariant pinned.

Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.

Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
  (was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
  reproduce on master and are unrelated to this PR).
2026-05-03 18:12:01 +00:00