Virtualization (#500/#4214) caused a scroll-up flicker on long sessions with
tall tool-call rows (variable-height anchor oscillation), distinct from the
stream-completion jump fixed in #4325. Per maintainer decision, flip the
'Virtualize long transcripts' preference to experimental/opt-IN (default OFF)
for everyone until the scroll behavior is reliable.
- api/config.py: virtualize_transcript default True->False; add
virtualize_transcript_optin marker (both bool-allowlisted); load_settings
force-off migration — a stored true WITHOUT the post-flip opt-in marker is
reset to off, so 100% of existing users land on off; an explicit opt-in
(marker present) is honored.
- boot.js / panels.js: default-off semantics (===true), persist the opt-in
marker on enable, checkbox not pre-checked.
- index.html / i18n.js (en): relabel experimental + flicker warning.
- Tests: rewrite the #4325 toggle assertions for the opt-in contract + 4 new
migration tests (unset/stale-true/explicit-optin/marker-without-true).
Phase B (root-cause scroll-stability) tracked separately. Thanks @b3nw.
Adds a Preferences setting 'Virtualize long transcripts' (default ON, opt-out).
When disabled, _currentMessageVirtualWindow returns a full non-virtualized window
so the entire transcript renders at once (also short-circuits the virtual
re-measure loop). Server-persisted (virtualize_transcript bool, default True),
hot-applies via renderMessages on toggle. i18n in all 13 locales.
Pairs with @rodboev's #4328 stream-end anchor scroll fix (cherry-picked into
this branch) to fully resolve#4325.
Independent review (APPROVED clean) + greptile both flagged one non-blocking nit:
_is_known_model_provider swallowed a transient _is_plugin_model_provider failure
at debug level, so a real plugin-backed provider could briefly vanish from the
picker with no visible log. Promoted to logger.warning(exc_info=True).
Also corrected the endpoint-error-hint comments in ui.js: they cited #4253, which
is actually a cron release (mislabel carried from #4279's body). Comment-only —
no logic change to the browser-verified collapsible-groups code.
Fixes the Photon phantom-providers regression from #4247 (v0.51.461): the
credential-pool detection loop added every auth.json credential_pool key to
detected_providers without checking it names a model provider, so the Photon
iMessage plugin's photon/photon_project/photon_user messaging credentials each
rendered as a phantom provider carrying the full auto-detected model catalog
(251 models on the reporter's machine).
Gated on a new _is_known_model_provider() check (custom:* slug, static
_PROVIDER_DISPLAY/_PROVIDER_MODELS entry, or registered model-provider plugin)
on both the load_pool and raw-dict fallback paths; the group-builder's generic
fallback no longer paints an unrecognized id with the global catalog. Real
pool-keyed providers (copilot, aliased google->gemini) still surface.
Integrates the stronger of two independent same-fix PRs — #4330's implementation
(thorough docstring, try/except-hardened plugin check, preserved #1881 comment,
defensive else branch) + its 8-case test that mocks at the WebUI boundary
(stubs hermes_cli, never imports the bundled agent package that's absent in CI,
exercises both the load_pool and ImportError raw-dict paths) — over #4329's
2-case test. Both rodboev (#4329) and the maintainer agent (#4330) independently
found and fixed the same regression.
Reported by Nic.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex gate caught a cross-profile leak: _CREDENTIAL_POOL_CACHE was keyed
by provider id alone, so a custom provider configured under profile A made
profile B report has_key=True from a stale cache hit (then 401 at request).
Key the cache by (active-profile auth-store path, provider id) at all
get/set/evict sites via _credential_pool_profile_tag(). Also make the two
new bare _entry.runtime_api_key reads getattr-safe (OAuth-only entries lack
the attr), matching the guard the author already applied at providers.py.
Adds a cross-profile isolation regression test.
Co-authored-by: akrhin <akrhin@users.noreply.github.com>
Custom/aliased providers with keys in the credential pool (added via
hermes auth add) were reported unconfigured in the WebUI because provider
detection only checked env vars. Add _has_explicit_pool_credentials() that
checks the pool while filtering the ambient gh_cli entry (so Copilot doesn't
falsely show configured on any machine with gh installed), cache-routed to
avoid the cold per-provider load_pool latency, and resolve provider aliases
before load_pool() at every retrieval site so detection and retrieval read
the same pool (glm->zai, github->copilot, etc.). /api/models/live wrapped in
profile scope; getattr-safe runtime_api_key for OAuth-only entries.
Co-authored-by: akrhin <akrhin@users.noreply.github.com>
Approve/deny on a remote-gateway backend now forwards to the gateway runs API
(run_id server-derived, existing auth+CSRF preserved, capability-probed + 60s
cached). Non-gateway/local approval path unchanged — relay only fires when a
session has an active gateway run (_STREAM_RUN_IDS). Old gateways get a clear
upgrade message instead of a silent no-op.
Co-authored-by: rodboev
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* stage-3712: model picker show-all overflow + fix per-row count leak (#3691)
Self-rebased rodboev's #3712 onto master. FIX (Nathan-caught): the per-row
provider chip stripped the '(N of M)' overflow count — that count belongs on the
group heading only, and was reading as nonsense post-expand (row 30 showing
'15 of 30'). Updated test to assert heading-carries-count + rows-do-not.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* stage-3712: + dedupe overflow expand (Codex SILENT) — reuse existing injected option
Codex caught: select hidden overflow model from search → reopen → Show all could
duplicate the row (_appendOverflowOptionsToGroup blindly appended every extra
model without checking the select for an already-injected option). Now moves/reuses
an existing same-value option instead of re-creating it.
* stage-3712: + fix expanded-heading double-count (Opus) — strip decoration, count rendered rows
Opus caught: post-expand the heading showed 'Nous (2 of 4) (4)' (appended its own
count onto the already-decorated backend label) and used modelCount (incl hoisted-
configured) vs rendered rows. Now strips the '(N of M)' suffix when expanded +
counts groupRows.length. Test asserts no double count on out[expanded].
* stage-3712: + include extra_models in Settings/Cron/Profile/Aux native selectors (Codex CORE)
Codex caught: the server overflow split moves models>15 into extra_models for ALL
providers, but the 4 native <select> builders in panels.js (settingsModel:6690,
cronFormModel:1112, profileFormModel:5784, _auxProviders:8287) read g.models only
— silently dropping the overflow tail for large providers. Now concat
models+extra_models at all 4 sites. Test guard added.
* Release PG: model picker show-all overflow for large provider catalogs (#3691)
Self-rebased rodboev's #3712 onto v0.51.445; full browser-tested (desktop+mobile,
before/after) + Nathan screenshot-approved. 4 gate-caught issues fixed inline:
per-row count leak (Nathan), overflow-expand dedup (Codex), heading double-count
(Opus), and native Settings/Cron/Profile/Aux selectors dropping the overflow tail
(Codex CORE). Codex SAFE + Opus SHIP + 41 picker tests green.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(#3988): default show_cli_sessions to true for new installs, grandfather existing OFF
Combines the best of #3997 (rodboev) and #4222 (Sanjays2402):
- config.py default flipped True so NEW installs surface CLI/TUI/Telegram/
Discord/Hermes One sessions in the sidebar without hunting for the toggle.
- load_settings() backfill grandfathers ESTABLISHED installs OFF: when the saved
settings predate the flip (onboarding_completed True + key absent), pin False so
an existing user's sidebar doesn't silently change. Fresh installs get True.
- Default-true hydration at every read site (config.py / boot.js / panels.js
checkbox) via the !==false idiom — no config-ON-but-UI-OFF mismatch (#4006 class).
- 10 tests: config default, bool-key round-trip, grandfather matrix
(fresh/mid-onboarding/established-absent/explicit-true/explicit-false/
save-preserves-grandfather), and both frontend hydration sites.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix(#4217): guard /api/file/delete and /api/file/rename against symlink targets
* chore(release): stamp v0.51.427 (Release ON) for #3988 + #4217
* chore: drop unused imports (ruff F401) in absorbed test files
* fix(#4217,#3988): address review — dangling-symlink ordering + boot.js catch fallback
Greptile review on #4230 + the related-PR reviews surfaced two fixes on the
shipped code:
1. (#4217) _handle_file_delete/_handle_file_rename checked target.exists()
BEFORE is_symlink(), so a DANGLING workspace symlink resolved to a missing
target -> misclassified as 404 'File not found' and left permanently
undeletable/unrenameable. Moved the no-follow is_symlink() guard before the
follow-based exists() check in both handlers. +2 dangling-symlink tests.
2. (#3988) boot.js settings-load-FAILURE catch block hardcoded
_showCliSessions=false, inconsistent with the new True default (the #4006
catch-block-fallback class). Now defaults true. +1 test pinning the fallback.
Both confirmed by the new tests (20/20 in the two files).
* fix(#3988,#4217): address Opus review — broaden grandfather signal + move-handler parity
Opus #4230 ship-review surfaced 2 LOW findings, both now fixed in-line:
1. (#3988) The grandfather keyed only on onboarding_completed, so a CLI-configured
user who tweaked a WebUI setting but never ran the wizard would get flipped ON
(and onboarding.py later auto-persisting onboarding_completed=True would make it
permanent). Now: established = show_cli_sessions absent AND (onboarding_completed
OR any persisted setting other than a falsy onboarding flag). A genuinely new /
still-mid-onboarding file still falls through to True. +1 test.
2. (#4217) _handle_file_move still checked exists() before is_symlink() — the same
dangling-symlink-404 bug just fixed in delete/rename. Reordered for parity so a
dangling symlinked source is rejected 400, not 404.
22 tests across the two files pass; ruff clean.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix: keep custom provider when model id has known-provider prefix (#4210)
Siblings of #3872 (v0.51.349, custom provider with a configured base_url
preserves intrinsic routing prefixes). The base_url unset variant of
the same bug was missed: with provider=custom or custom:<slug> and no
base_url set, a model id like 'x-ai/grok-2' or 'google/gemma-2' was
hijacked to provider='openrouter', base_url=None. The backend then
failed to initialize with 'RuntimeError: No LLM provider configured'
because the user had no OpenRouter credentials configured.
resolve_model_provider() now applies the same _is_custom exemption the
config_base_url branch already carries to the cross-provider redirect
at the end of the slash branch. The custom provider keeps the request
and the full model id; real first-party providers pointed at an
OpenAI-compatible proxy (e.g. provider=openai + base_url=litellm) are
unaffected because their strip path is in the base_url branch and the
'prefix == config_provider' branch above the change.
Verified:
- test_model_resolver.py 32 passed (30 existing + 2 new RED tests)
- test_custom_provider_bare_model_reasoning.py 34 passed
- Pre-existing F401 (api/config.py:23,24,28) are not introduced by
this change; same 3 errors exist on upstream/master.
Refs #4210
* chore(release): reposition #4210 CHANGELOG entry under v0.51.426 (OM)
---------
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* feat(workspace): add Todos tab to workspace panel behind a setting (#2942)
* fix(workspace): use DOM attribute to check active tab across script scope
* Prefer the live todos snapshot in the workspace tab
* Fill the missing workspace Todos locale keys
* feat(workspace): add optional Todos tab to workspace panel (#3564)
Rebased rodboev's #3564 onto current master + CHANGELOG entry.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3564): apply workspace Todos tab visibility on cold boot
Codex gate: boot.js set window._workspaceTodosTab from saved settings but
never called _applyWorkspaceTodosTabVisibility(), so a previously-enabled
Todos tab stayed hidden on a normal page reload (only re-appeared after
opening Settings). Call the visibility helper after assigning the flag in
both the settings-apply and the fallback/default paths.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.422 (Release OI) for #3564
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(#3750): preserve LM Studio auth on reasoning probe
Rebased rodboev's #3837 onto current master (master reworked
resolve_model_reasoning_efforts via #3431 with a combined hermes_cli
import + _nested_route_reasoning_denied guard). Threaded the api_key
resolution + the _lmstudio_model_reasoning_options wrapper + the
HTTP-fallback probe into master's current structure; intent preserved
byte-identically (+164/-24, same as the original PR diff).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3837): gate LM Studio probe credential to configured endpoint + no-redirect opener
Codex security review (CORE) on the rebased #3837: GET /api/reasoning accepts
a caller-supplied base_url, and the new probe attached the configured LM Studio
API key as a Bearer token; urllib also re-sends Authorization across redirects.
Together that could exfiltrate the stored credential to an attacker-controlled
host. (master only escalates here because master probed WITHOUT a key.)
Fixes:
1. Only forward the LM Studio credential when the probe target normalizes to
the configured LM Studio base URL (reuses _normalize_base_url_for_match);
a caller-supplied non-matching base_url is probed keyless.
2. _lmstudio_reasoning_probe_options_fallback uses a no-redirect opener
(_NoRedirectHandler) so a 3xx can't forward the Authorization header onward.
3. Two regression tests: caller-supplied base_url drops the key; redirect is
not followed (credential never reaches the redirect target).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3837): route ALL credentialed LM Studio probes through the no-redirect probe
Codex re-gate caught that the first fix only hardened the built-in fallback;
in production hermes_cli IS importable, so the preferred path called
hermes_cli.models.lmstudio_model_reasoning_options(..., api_key=...) which uses
plain urlopen and still follows redirects with the credential.
Fix: _lmstudio_model_reasoning_options now short-circuits to the built-in
no-redirect probe whenever api_key is set; the hermes_cli path is used ONLY for
keyless probes (no credential to leak). Reworked tests accordingly:
- test_credentialed_probe_never_calls_hermes_cli: credentialed probe bypasses CLI
- test_keyless_probe_logs_signature_mismatch_before_fallback: keyless CLI degrade
- test_credentialed_probe_does_not_follow_redirects_end_to_end: E2E through
resolve_model_reasoning_efforts with real hermes_cli; redirect target never hit
Verified both security tests fail without the guard and pass with it.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.420 (Release OG) in CHANGELOG
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
get_reasoning_status() returned the raw config reasoning_effort, so a stale saved
'max' surfaced on boot/status/chip even though streaming coerces it to xhigh. Now
routes the status value through coerce_reasoning_effort_for_model() with the resolved
model/provider/base_url, so all read paths agree. Added a stale-max->xhigh status
regression test.
The richer static-catalog builder's group-building loop used a strict item["id"]
extraction that would KeyError (caught → degrade to the minimal one-model catalog)
for legal config shapes where providers.<id>.models is a list of dicts keyed by
"model"/"name" rather than "id". Mirror the tolerant id-or-model-or-name resolution
the detection loop already uses, skip entries with nothing usable, and add a
regression test. No behavior change for the common list-of-strings / list-of-id-dicts
shapes.
docs(changelog): stamp #4052 model-picker budget fallback as v0.51.370 (Release MI)
Two scroll-state bugs cause the viewport to snap to the bottom after
streaming completes, even when the user has scrolled up to read:
1. _scrollAfterMessageRender (ui.js): when preserveScroll=true and
_scrollPinned=true (re-set by the 250px near-bottom hysteresis),
_followMessagesAfterDomReplace() calls scrollToBottom(), overriding
the user's position. Added !_messageUserUnpinned guard so explicitly
scrolled-up users get their snapshot restored instead.
2. _finishDone (messages.js): the explicit scrollToBottom() at L3302
fires when shouldFollowOnDone is true, but that flag only checks a
120px near-bottom threshold. Added _isMessagePaneNearBottom(250)
gate so users reading 250px+ above bottom keep their position.
Also adds a new 'auto_scroll_follow' setting (default: off) that gates
scrollIfPinned() and _shouldFollowMessagesOnDomReplace(). When disabled,
the viewport never auto-scrolls during streaming — the user controls
scroll position manually and uses the ↓ button to jump to bottom.
7-file setting-add pattern applied:
- api/config.py: _SETTINGS_DEFAULTS + _SETTINGS_BOOL_KEYS
- static/boot.js: both init paths
- static/ui.js: scrollIfPinned() + _shouldFollowMessagesOnDomReplace()
- static/index.html: checkbox in appearance settings
- static/i18n.js: en + zh-CN translations
- static/panels.js: payload, autosave, populate, full save
- messages.js: no additional changes (gated via _shouldFollowMessagesOnDomReplace)
Tests updated: test_tars_scroll_reset_regressions, test_issue1690,
test_issue3545. All 116 scroll-related tests pass.
- _load_models_cache_from_disk: resolve _get_models_cache_path() once
(was called twice — exists() then open()) (greptile P2).
- conftest: use a _MISSING sentinel for the _active_profile restore guard so
it restores whenever api.profiles was importable pre-test, independent of the
value (greptile P2; _active_profile defaults to 'default' so the prior
is-not-None guard was functionally fine but technically value-dependent).
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Codex + Opus both independently caught a CORE gap in the first cut: the
bounded /api/models rebuild runs on a detached 'models-catalog-rebuild' daemon
thread that inherits neither the request-profile thread-local (#798) nor
os.environ. So on a non-default profile the worker probed the DEFAULT profile's
credentials and, when the 4s budget was exceeded, published the rebuilt catalog
to the DEFAULT profile's disk cache (cross-contamination) — exactly the slow
path a non-default cold rebuild takes.
Fix:
- profile_scope_for_detached_worker(profile_name): sets the request-profile TLS
AND applies the profile .env on the worker thread, restoring both on exit
(no-op for default). Distinct from profile_env_for_active_request (which reads
the current thread's TLS and must not clear it).
- get_available_models() captures the active profile on the request thread and
wraps the rebuild worker body (probe + over-budget publish + disk save +
fingerprint) in that scope; the legacy synchronous rebuild applies the profile
env on the foreground. /api/models route no longer wraps (the work moved into
get_available_models so ALL callers — chat/start, resolution — are fixed).
- 2 new regression tests incl. the worker-thread before/inside/after assertion.
Empirically verified: a fresh worker thread resolved models_cache.json/default
WITHOUT the scope (the bug) and models_cache.work.json + the work .env + the
work auth.json WITH it.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
On a non-default profile, Settings → Providers timed out and the model
picker showed only the default profile's models. WebUI profile switching is
per-client/cookie-scoped (#798), but two read-only paths resolved from the
process-global default profile:
- Facet A: /api/providers + /api/models did not apply the active profile's
.env around the read, so get_auth_status() / provider_model_ids() / custom
key lookups resolved the default profile's credentials. On a non-default
profile the auth probes could stall past the 30s frontend abort.
- Facet B: the /api/models disk cache was a single import-time
STATE_DIR/models_cache.json shared across every profile, while the cache
fingerprint is profile-specific -> a non-default profile rejected the shared
snapshot every read and cold-rebuilt (the slow serial-probe path).
Fix:
- api.profiles.profile_env_for_active_request(): applies the active
per-request profile's .env for the duration of the read (delegates to the
existing profile_env_for_background_worker used by streaming). No-op for the
default/root profile, so single-profile deployments are byte-identical.
- api.config._get_models_cache_path(): profile-keys the disk cache filename
(models_cache.<profile>.json) derived from the default path; default profile
keeps models_cache.json unchanged (no file migration).
- routes.py: wrap both GET handlers in profile_env_for_active_request.
- conftest: restore api.profiles._active_profile + clear request-profile TLS
after each test (a pre-existing isolation hole that profile-keyed cache
paths newly surface under sharding).
Tested: 10 new regression tests; live before/after on isolated servers shows
a non-default profile now surfaces its configured provider (deepseek) with
its own credentials + its own cache file, where master showed only the
default profile's providers and one shared cache.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Adds an opt-in, default-off, desktop-only floating panel that lists the user's
questions in a conversation as a numbered jump-list (click to scroll + flash the
message). Gated behind a Settings → Preferences toggle (show_conversation_outline)
wired through the existing show_* preference boot/load/autosave/save paths.
Review follow-ups applied on absorb:
- Outline is now strictly chat-only: leaving the chat view (Settings, Tasks,
Insights, …) hides the toggle button AND closes the panel; returning to chat
restores the toggle (panel stays closed until re-opened). Implemented by gating
_outlineAllowed() on the active panel and re-evaluating via a MutationObserver on
the <main> showing-<panel> class (switchPanel is a global fn declaration that
can't be reliably wrapped from this script).
- Fixed a latent visual bug: #outlinePanelWrapper{display:flex} (id selector)
outranked the UA [hidden]{display:none}, so wrapper.hidden=true never actually
hid the panel — the × close button and auto-close had no visual effect. Added
#outlinePanelWrapper[hidden]{display:none;}.
- Regression tests for both.
Closes#2124.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
_resolve_compatible_session_model_state() no longer reverts an explicit
@provider:model selection to the default when the provider's group is missing from
the cached catalog snapshot. explicit picks always honored; non-explicit (2nd+ turn
/ chat switch) preservation requires the provider to be KNOWN/CONFIGURED via the new
_provider_is_known_or_configured() (static registry + custom-provider config, NOT the
cold catalog) — so a cold live-discovery provider (ollama-cloud/deepseek/xai) is
preserved while a genuinely-unknown provider (@removed:...) falls through to
default-repair. A known-but-unconfigured builtin is deliberately preserved (surfaces
a clear runtime auth error rather than a silent swap; a cheap env/config credential
check would mis-classify OAuth/auth-store providers). Keeps the #3867 cached-catalog
hot path intact.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
When SESSION_DIR has no session .json files and the index is empty/absent,
print_startup_config() now scans sibling state directories and prints a diagnostic
pointing the user at the populated sibling + the HERMES_WEBUI_STATE_DIR to set —
fully fail-safe (try/except, warning-only, no behavior change).
Closes#3915.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
A bare 'custom' provider with a remote base_url is a vendor-routing proxy
(LiteLLM, Bedrock gateway, etc.). A slashed model id like bedrock/opus-4-6 is
intrinsic — the proxy routes on the full string. The prior heuristic stripped
ANY known-provider prefix (bedrock is also a provider name), truncating it to
opus-4-6 and causing 403 'model not allowed'.
Fix: in the custom-base_url branch, strip the prefix only when (a) the configured
provider is a real first-party provider pointed at an OpenAI-compatible proxy
(provider=openai + proxy base_url — the #433 path), OR (b) for a bare 'custom'
provider, the bare id is genuinely a first-party model of that prefix's catalog
(openai/gpt-5.4 -> gpt-5.4, since gpt-5.4 IS an OpenAI model). An intrinsic
routing prefix whose bare id is NOT first-party of that namespace is preserved
(bedrock/opus-4-6 stays whole). Unknown prefixes (zai-org/...) preserved as before.
Both prior behaviors work in tandem with the fix:
- #433 sprint40: bare custom + openai/gpt-5.4 -> gpt-5.4 (redundant, strip)
- #433 model_resolver: provider=openai + google/gemma -> gemma (proxy strip)
- #548: custom + zai-org/GLM-5.1 -> preserved (unknown prefix)
- #1625: custom + loopback base_url -> preserved (local server)
- #3872: custom + bedrock/opus-4-6 -> preserved (intrinsic vendor prefix)
Adds regression tests covering all of the above.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: haolf000 <haolf000@users.noreply.github.com>
* stage bg_task trio combined (#2979 superset) on master for deep review
* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test
* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix
* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)
* bg_task trio: apply 3 independent-review (greptile) fixes
1. start_session_turn now threads the session PROFILE model defaults
(_read_profile_model_config) into the wakeup model-resolve, so a brand-new
session with an empty model falls back to the profile default not global
DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
timestamp map can't grow one permanent entry per session forever.
nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.
* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
* refactor(sessions): add dormant JSON-backed SessionDB adapter (#3720, #3383)
First, lowest-risk slice of the unified-session-db migration: a SessionDB-shaped
adapter over the existing WebUI JSON store, behind a dormant experimental flag
(experimental.unified_session_db, default false). No runtime call site is rewired
— is_unified_session_db_enabled() has no live callers, so persistence behavior is
unchanged until a later migration PR opts in. Includes the adapter, the dormant
config flag + _apply_config_defaults wiring, an architecture doc, and adapter tests.
Also adds docs/architecture/ to the .gitignore docs allowlist (the docs/* rule
excludes subdirectories; the new architecture doc and its presence test would
otherwise be silently dropped on a clean checkout).
Co-authored-by: rodboev <[email protected]>
* docs(changelog): stamp v0.51.305 — Release JU (stage-p2b #3720)
---------
Co-authored-by: nesquena-hermes <[email protected]>
* fix(cron): toggle run output rows instead of re-fetching when already open (#3732)
_loadRunContent() only ever expanded, so clicking an already-open cron run row
re-fetched its content pointlessly. It now toggles: an open row collapses (clears
the expansion state + resets the toggle button) and returns early, avoiding the
redundant API call.
Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>
* feat(config): expand ${VAR} references in config.yaml at load time (#3736)
hermes-agent already expands ${ENV_VAR} in config.yaml, but the WebUI's own
loader stored the raw dict, leaving literal ${...} strings. Recursively expand
${VAR} against os.environ on both config load paths (reload_config and
_load_yaml_config_file); unset vars are left untouched (${VAR} preserved).
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>
* fix(security): anchor untracked-file deletes in git_discard (#3702)
git_discard(delete_untracked=True) used raw shutil.rmtree / Path.unlink after a
separate safe_resolve_ws validation, leaving a validation-to-use symlink-swap
window. Route untracked deletes through the anchored helpers (rmtree_anchored /
unlink_anchored) so a swapped path component is rejected at delete time; preserve
the prior missing_ok tolerance for benign concurrent-removal races. Adds
regression coverage for both the symlink-swap block and the concurrent-missing case.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): stamp v0.51.303 — Release JS (stage-p1a #3732#3736#3702)
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* Harden interrupted recovery control filtering
* Redesign live-to-final assistant replies
* Fix live activity anchor test fixture
* Fix CI lint issues for live reply tests
* Strengthen live progress prompt contract
* Recover PR #3401 refresh on origin/master
* Repair live-to-final refresh regressions
* Fix live worklog refresh regressions
* Show live footer timer on initial stream start
* Restore live stream shell after reload
* Preserve per-frame live SSE replay cursors
* Preserve reasoning as Worklog Thinking cards
* Quiet Worklog Thinking card styling
* Align Worklog Thinking card styling
* Scope live Worklog Thinking cards by segment
* Suppress exact duplicate settled Thinking
* Close#3401 merge review test gaps
* fix(#3401): resolve 4 deep-review regressions (inline-think, reconnect-dup, neon skin, busy-gate worklog)
Deep review (Codex diff-vs-master + live-browser drive) of the live-to-final refactor
surfaced 4 regressions vs master that the rewritten suite no longer guarded:
1. Inline <think>…</think>answer reasoning vanished — _assistantReasoningPayloadText
used $-anchored regexes so a leading think block + visible answer extracted nothing
and the Thinking card never rendered. Removed the 3 $ anchors to match the
(non-anchored) display stripper. Live: inline-think thinking-only turn now renders.
2. (CORE) reconnect/reload duplicated the live reply — _rememberRunJournalCursor advanced
a closure-local seq but never wrote INFLIGHT[activeSid].lastRunJournalSeq, so a reload
replayed the journal from after_seq=0 over restored lastAssistantText. Now mirrors the
cursor onto INFLIGHT + schedules a throttled persist.
3. Neon skin silently broke — PR deleted the :root[data-skin="neon"] CSS but left Neon in
the picker. Restored the neon CSS block from master.
4. Settled tool-worklog rebuild gated purely on !S.busy — dropped every prior settled
turn's worklog when renderMessages re-ran during an active stream (switch-back to an
in-progress session). Restored master's !S.busy || (S.toolCalls && S.toolCalls.length).
Live: busy re-render now preserves tool cards (4→4, was 4→0).
Live-verified all 4 + confirmed #3709/#3592 invariants still hold (1 thinking card, none
below footer; distinct siblings preserved). + tests/test_issue3401_deep_review_fixes.py (7).
* test(#3401): realign 3 stale source-shape assertions to the deep-review fixes
Fix commit changed two source literals that existing stage tests scanned for:
- test_live_activity_timeline.py (x2): split anchor 'if(!S.busy){' → the restored
'if(!S.busy || (S.toolCalls&&S.toolCalls.length)){' guard (fix 4).
- test_run_journal_frontend_static.py: 'after_seq=0' not in source — fix 2's comment
contained that literal; rephrased the comment to 'the zero floor (after_seq of 0)'.
Intent of all three assertions unchanged; only the matched string updated. No code
behavior change.
* docs(changelog): v0.51.294 — Release JJ (stage-3401, #3401 live-to-final redesign)
---------
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Nathan-Hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena-hermes <[email protected]>
* feat(sidebar): add show_cron_sessions toggle to surface cron sessions (#3514, #2841)
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* feat(sidebar): add manual session status labels (#3570)
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* docs(changelog): v0.51.284 — Release IZ (stage-w4)
* fix(settings): persist show_cron_sessions in the explicit Save Settings path too (#3514)
Codex regression-gate follow-up: the autosave path (_preferencesPayloadFromUi)
included show_cron_sessions but the explicit saveSettings() button path read/saved
show_cli_sessions and dropped the cron checkbox — clicking Save Settings silently
omitted it. Read settingsShowCronSessions + add body.show_cron_sessions (gated on
CLI sessions, mirroring autosave).
* fix(settings): gate show_cron_sessions identically in BOTH save paths (#3514)
Codex round-2: my saveSettings() gate exposed that the autosave path
(_preferencesPayloadFromUi) posted the raw cron checkbox state ungated, so
show_cli_sessions=false + show_cron_sessions=true could persist via autosave.
Gate autosave on showCliCb too; update the regression test to assert both
paths gate on settingsShowCliSessions.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Adds the Verdigris dark-only appearance skin (emerald/forest-green + bronze-gold),
renamed from the contributor's 'Hermes Agent' to a descriptive material name per
maintainer naming convention. Registered across all 5 skin sites (config allowlist,
boot.js swatch, index.html FOUC map, i18n in 12 locales, scoped CSS palette) + test.
Also fixes the zeus i18n test (zeus is no longer the trailing skin token).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
## Release v0.51.256 — Release HX (stage-r4)
Performance — bound WebUI memory growth & idle CPU on large installs.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3506 | @nesquena-hermes (reported w/ profiling by @djenttleman) | On a large install (~615 sessions / 40k messages / 454 MB state.db) the WebUI process climbed ~100 MB → ~1.5 GB RSS over days and held high idle CPU. Three root causes fixed: (1) `session_lifecycle._sessions` grew unbounded → new `discard_session()` drops the entry at agent-eviction boundaries, only when no in-flight commit / no uncommitted memory work (retry invariant preserved); (2) cache caps now operator-tunable (`HERMES_WEBUI_AGENT_CACHE_MAX` default 50→25, `HERMES_WEBUI_SESSIONS_MAX`); (3) GatewayWatcher computes a cheap fingerprint before the expensive per-session `MAX(messages.timestamp)` projection and only re-projects on change. |
### Rebase + review notes
- Rebased onto current master; the code diff was verified **byte-identical to the nesquena-APPROVED head** at rebase time (only CHANGELOG re-resolved).
- The Codex regression gate then surfaced **two correctness gaps** the approval didn't catch, both fixed here with regression tests:
1. **Watcher fingerprint missed same-count transcript rewrites.** `/retry`,`/undo`,`/compress` (`SessionDB.replace_messages`) rewrite messages with new timestamps but can leave `message_count` unchanged → stale sidebar `last_activity`. Fixed with a **per-session** grouped message aggregate (`id, count, user_count, MAX(timestamp)`) over the same non-excluded sessions (a global MAX would miss a rewrite of an older, non-newest session); cron/webui stay excluded so idle churn still doesn't re-project.
2. **LRU agent-cache eviction could close a live worker's agent** (`popitem(last=False)`, liveness-blind — pre-existing, but the lower 50→25 cap made it more likely). Eviction now snapshots `ACTIVE_RUNS` session_ids (before the cache lock — no nested lock) and skips live sessions, deferring (temporarily exceeding cap) rather than closing a live agent.
### Gate
- Full pytest suite: **7612 passed, 0 failed** (one boot-cascade flake re-run; clean on re-run)
- ruff: CLEAN · Codex (regression): 4 rounds → both gaps + a stale test fixed → **SAFE TO SHIP**
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
## Release v0.51.249 — Release HQ (stage-q21)
Small opt-in feature. UX-approved (screenshot of the toggle in Settings → Preferences).
### Added
| Issue | Author | Feature |
|-------|--------|---------|
| #2974 | @rodboev | **"Auto-expand terminal on output"** preference (Settings → Preferences, **off by default**). When enabled, the collapsed embedded terminal panel expands automatically the first time a running command emits output. Fires once per stream (guarded on open && collapsed — not per chunk), and uses `expandComposerTerminal({focus:false})` so it doesn't steal focus from the composer. Backend-persisted boolean mirroring the `simplified_tool_calling` pattern; default-off = no behavior change on upgrade. |
### Gate
- Full pytest suite: **7557 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN · screenshot vision-verified (toggle renders cleanly under "Compact tool activity", unchecked by default)
- Codex (regression): **SAFE TO SHIP** (clean first pass) — no-arg `expandComposerTerminal` callers unchanged, default-off incl. settings-load-failure path, all 5 plumb sites mirror the existing pattern
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
## Release v0.51.247 — Release HO (stage-q19)
Backend correctness fix.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3505 | @franksong2702 | **Reasoning effort is coerced to a level the active model/provider actually supports** before each request, instead of being sent verbatim and rejected. `openai-codex` `gpt-5` no longer gets `max` (→ `xhigh`); `o1`/`o3`/`o4` clamp to `low`/`medium`/`high`. Coercion only steps *down* (never escalates); `none`/unset preserved. The capability filter is applied across heuristic / models.dev / Copilot / LM Studio paths. |
This is the narrow, correct fix for the detection gap that #3431 tried to address by removing the chip-visibility gate (which we shelved). The chip-visibility gate is **untouched** (Codex confirmed) — `get_reasoning_status`/`_applyReasoningChip` still hide the chip for unconfirmed models.
### Review fix absorbed (Codex + self-flagged)
The first cut **dropped** a configured effort for *unrecognized* models, because capability detection returns `[]` for both "known-unsupported" and "simply-unknown" (custom providers, aggregator-rewritten ids, new releases) — that's a behavior change vs master (which sent it verbatim) and would silently disable reasoning. Fixed: an **empty** capability set now **preserves** the configured effort (provider stays the final authority; worst case = the same rejected request master already produces, i.e. no regression). Known-bad clamps return *non-empty* filtered sets, so they still degrade correctly. Nathan chose this "preserve-for-unknown" behavior. + regression test.
### Gate
- Full pytest suite: **7548 passed, 0 failed**
- ruff: CLEAN · 48 reasoning tests pass (incl. preserve-for-unknown + codex-clamp + never-escalate)
- Codex (regression): SHIP-ONLY-WITH-FIXES (unknown-model drop) → fixed → **SAFE TO SHIP**
- Verified empirically: gpt-5/codex max→xhigh, o3 max/xhigh→high, unknown high→high (preserved), none/unset preserved
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
## Release v0.51.242 — Release HJ (stage-q14)
UX-approved via Telegram (Nathan — dark/light/mobile screenshots).
### Added
| PR | Author | Feature |
|----|--------|---------|
| #3440 | @t3chn0pr13st | **Graphite appearance skin** — a quiet, neutral-gray "workbench" alternative to the default gold/cream. Selectable from Settings → Appearance and `/theme skin graphite`. Both light + dark palettes on the existing CSS-variable token system; tightened typography, shadows, active-sidebar spacing, code-block framing. |
### Why it's safe
- **Fully scoped + additive**: every new CSS rule (and every `!important`) is under `[data-skin="graphite"]` — Codex verified zero bleed into the default appearance or other skins. `api/config.py` keeps the default skin as `default` and only *adds* `graphite` to the allowed set. No i18n keys dropped (only the `/theme` help string gains `graphite`).
- Opt-in; a user has to select it. Default experience unchanged.
### Test-robustness fix (absorbed)
The new graphite scoped selectors (e.g. `:root[data-skin="graphite"] .session-item.active .session-time{…}`) appear in `style.css` *before* the canonical unscoped rules, which broke 3 naive first-occurrence CSS-contract tests (`test_issue677` scroll-btn-overlay, `test_issue856_pinned_indicator_layout`, `test_workspace_panel_session_list`). Fixed those 3 to anchor on the canonical **unscoped** rule (start-of-line regex) instead of the first `.selector` match — robust against this and future skins. Verified they still pass on clean master CSS (invariant preserved, not weakened).
### Gate
- Full pytest suite: **7517 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** (verified all CSS scoped, default skin unchanged, no i18n key loss)
- Vision-verified dark + light + mobile; UX-approved by Nathan via Telegram
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
* Release v0.51.228 (stage-p12): workspace tree-drop #3411 + large-markdown preview #3378#3411 (@pamnard): stopPropagation on workspace file-tree OS-file dragenter/dragover/drop
so a tree drop uploads to the workspace WITHOUT also attaching to the composer.
#3378 (@starGazerK): raise md rich-render ceiling 64KB/1500L->256KB/5000L + backend
file read 200KB->400KB, add 'Render as markdown anyway' force button (reuses cached
raw content, no extra fetch).
* fix(workspace): force-render uses fresh path-scoped cache, blocked while dirty (#3378 Codex follow-up)
Codex review of #3378 found the markdown force-render path had two SILENT bugs:
(1) saving a md file from the plain-text fallback didn't update _previewRawContent,
so a later force-render showed stale pre-save content; (2) the cache-reuse check
'path===_previewCurrentPath' was tautological (var just assigned), so a force-render
after a file switch could render the previous file's cached content. Fixes: track
_previewRawContentPath (set on fetch AND save), reuse cache only when it matches the
requested path, and block force-render while the editor is dirty/open. +3 regression
tests. (#3411 was cleared clean by Codex.)
---------
Co-authored-by: nesquena-hermes <[email protected]>
* feat(composer): replace mobile config-button sliders icon with context-usage ring (#3062, @NottheGuy007)
Squashed net diff of #3062 (the PR branch's tip commits were deletes of files not
present in our tree). Replaces the composerMobileCtxBadge text badge with an SVG
progress ring (ctx-arc + ctx-num) showing real-time context-window usage: ring
fill via stroke-dashoffset, centered percentage, color-coded green<=50%
orange<=85% red>85%. Ring resets to 0%/green on new session.
* feat(activity): add 'expand activity feed by default' appearance setting (#3080, @AJV20)
Squashed net diff of #3080. Adds a Settings -> Appearance checkbox
(activity_feed_expanded_default, default off) to expand new Activity disclosures
by default; preserves manual per-turn collapse/expand (explicit toggle still
wins); live 'Waiting on model' rows explain what the agent is doing before/after
tool calls. i18n keys for all locales.
* docs(changelog): v0.51.226 — context-usage ring (#3062) + activity-feed default-expand setting (#3080)
* test(mobile): update touch-target test for #3062 ring (badge removed)
#3062 replaced the composerMobileCtxBadge text badge with the SVG context-usage
ring (composerMobileCtxRing) but left 3 stale references in tests/test_mobile_layout.py.
The full suite caught test_mobile_composer_primary_controls_keep_touch_friendly_sizing
asserting the removed .composer-mobile-ctx-badge CSS rule + #composerMobileCtxBadge
element. Updated the assertion to the new ring: confirm composerMobileCtxRing exists,
the old badge is fully gone (not dangling), and the ring SVG is aria-hidden so it
stays a decorative overlay that doesn't steal the config button's 44px touch target
(which is still asserted via .composer-mobile-config-btn above). 56/56 mobile-layout
tests pass. Codex: no production JS dereferences the removed badge.
---------
Co-authored-by: nesquena-hermes <[email protected]>
* fix: respect profile toolset/skill config on WebUI streaming worker (#3294)
The streaming agent runs on a detached worker thread that does not inherit
the per-request thread-local profile context (set from the hermes_profile
cookie on the HTTP handler thread). On that worker, the ambient get_config()
resolves via get_active_profile_name() which falls back to the process-global
_active_profile (usually 'default'). A session under a non-default profile
with an empty platform_toolsets.cli therefore loaded the DEFAULT profile's
full toolset list, inflating a tools-disabled profile's prompt from ~400 to
~15K input tokens.
Add api.config.get_config_for_profile_home() — a race-free direct disk read
of an explicit profile home's config.yaml (no shared-cache mutation), which
defers to get_config() when the requested home matches the ambient path so
in-memory test overrides are preserved. The streaming worker now resolves
_cfg from the session's own profile home, fixing toolsets, prefill context,
and fallback chains in one place.
Closes#3294
Co-authored-by: gottipx <gottipx@users.noreply.github.com>
* docs(changelog): v0.51.224 — #3294 profile toolset config (dropped #3405 to hold per Codex stale-model-repair finding)
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: gottipx <gottipx@users.noreply.github.com>
* feat: upgrade MiniMax default model to M3
Add MiniMax-M3 as the new default and prune deprecated older
versions (M2.5/M2.5-highspeed/M2.1/M2) from the model catalog.
M2.7 (and M2.7-highspeed) is retained as the legacy compatible
option for users who pin to it.
Updated:
- api/config.py: _FALLBACK_MODELS adds minimax/MiniMax-M3 (placed
before M2.7 so the dropdown surfaces it first)
- api/config.py: _PROVIDER_MODELS['minimax'] adds M3 first, removes
M2.5/M2.5-highspeed/M2.1
- api/config.py: _PROVIDER_MODELS['minimax-cn'] adds M3 first,
removes M2.5/M2.1/M2
- tests/test_minimax_provider.py: updated CN catalog assertions
to match the new {M3, M2.7} list
API URL and TTS configuration are unchanged.
Co-Authored-By: Octopus <liyuan851277048@icloud.com>
* fix(models): register openai-api as first-class picker provider
* fix(models): detect OPENAI_API_KEY as openai-api, not bare openai (#3443 Codex follow-up)
Codex review found #3444 added the openai-api picker entry but the env-detection
side still did detected_providers.add('openai') for OPENAI_API_KEY. The agent
registry has only openai-api and openai-codex (no bare openai), so a env-only
OPENAI_API_KEY setup emitted @openai: picker entries the agent can't resolve on
the send path. Detect openai-api to match the registry. Adds a regression test.
* docs(changelog): v0.51.223 — re-stamp keep-set (#3443 openai-api + #3374 MiniMax-M3); dropped #3289 + #3264 to hold per Codex
---------
Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: Octopus <liyuan851277048@icloud.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
v0.51.218 fixed getModelLabel() (the visible chip) for URI-scheme model IDs but
left the same first-segment-slash-strip bug in the matching/dedup paths:
api/config.py _norm_model_id + _get_label_for_model, and static/ui.js
_normalizeConfiguredModelKey. For gpt://folder/model/latest those treat the
path slashes as provider delimiters, mis-normalizing the model-identity key
(the #3360 collision class, for URI ids). Adds a URI-scheme guard to all three
so the scheme is not stripped. Kept v0.51.218's getModelLabel (more thorough
than #3436's variant — it extracts the model name vs returning the whole id);
took #3436's backend + _normalizeConfiguredModelKey halves with backend/frontend
parity tests.
Co-authored-by: b3nw <b3nw@duck.com>
Follow-up to PR #3382 review feedback. There are three GitHub-related
env vars that can be auto-detected:
- COPILOT_GITHUB_TOKEN: user-specific config, should NOT be filtered
- GITHUB_TOKEN: classic PAT, should be filtered (already done)
- GH_TOKEN: another env var, should also be filtered (this commit)
Use a frozenset (_AMBIENT_GH_ENV_SOURCES) to clearly define which
env var sources are ambient vs user-configured.
Copilot API does not support classic PATs (ghp_*), only OAuth tokens (gho_*)
or fine-grained PATs (github_pat_*). When GITHUB_TOKEN env var contains a
classic PAT, it should be filtered from the credential pool to prevent
Copilot from appearing in the model picker dropdown.
The _is_ambient_gh_cli_entry() function now also filters entries with
source='env:GITHUB_TOKEN', treating them as ambient/auto-detected rather
than user-explicitly-added credentials.
Fixes issue where Copilot group appeared in WebUI model picker even when
gh auth token was an unsupported classic PAT.
When a custom/proxy provider serves models whose IDs share the same base
name across vendor prefixes (e.g. vendor_a/deepseek/deepseek-v4-pro vs
vendor_b/deepseek/deepseek-v4-pro), several normalization functions use
split('/').pop() (or split('/')[-1]) which discards all segments except
the last. This causes three user-facing symptoms: (1) clicking one
model selects a different colliding model, (2) configured-model badges
attach to the wrong dropdown entry, and (3) the model-chip label in the
composer bar is truncated to just the base model name.
Root cause: all three callers take only the last slash-segment instead
of stripping only the first (provider) segment and preserving the
remaining vendor hierarchy.
Fix 1 — _findModelInDropdown (static/ui.js): Move the exact string match
before the provider-aware normalized match. Previously, when all models
share the same provider ID (common with LLM proxy setups), the normalized
match returned whichever colliding option appeared first in DOM order,
even though an exact match existed.
Fix 2 — _normalizeConfiguredModelKey (static/ui.js) and _norm_model_id
(api/config.py): Replace split('/').pop() / split('/')[-1] with a first-
segment-only strip (regex on frontend, split('/',1) on backend), matching
the strategy already used by _findModelInDropdown's norm lambda. This
prevents multi-slash IDs from colliding in badge assignment and the
configured-entry dedup set. Additionally, strip colon-qualified provider
prefixes (e.g. custom:name/) before the slash strip so badge-key variants
like 'custom:llm-proxy/opencode_go/model' merge correctly with the bare
'opencode_go/model' in the configured section dedup.
Fix 3 — getModelLabel (static/ui.js) and _get_label_for_model
(api/config.py): Same split('/').pop() to first-segment-strip change so
the composer-bar model chip and backend label preserve vendor context
(e.g. shows 'opencode_go/deepseek-v4-pro' instead of 'deepseek-v4-pro').
Verification: 9 new regression tests (test_issue3360) covering exact-
match priority, multi-slash normalization, and backend/frontend parity.
Updated 1 existing test (test_norm_model_id_trailing_empty_guard) that
asserted the old split('/').pop() pattern. All 25 related tests pass.
AI Usage: Gemini (gemini-2.5-pro), via Antigravity IDE, pair-programmed.
(cherry picked from commit a454fecd2b3a83f7da34473c883be069171aeac9)
Codex found two more once the config-guard bug was fixed (Opus concurred on #2):
1. panels.js _buildPluginCard built the Open button + enable toggle with inline
onclick/onchange that interpolated tab.path / plugin.key into a JS-string-in-
attribute context — HTML-escaping is insufficient there (quote breakout).
Now rendered inert + bound via addEventListener with RAW closure values.
2. tab.path was unvalidated. Added _VALID_PLUGIN_TAB_PATH (^/[A-Za-z0-9._~/-]{0,255}$)
in load_plugins() — absolute, no quotes/query/fragment/control chars.
Also Opus nit: deep-merge now coerces dashboard_plugins values to bool + str keys.
Regression tests added for both.
CRITICAL (Opus HALT on prior commit): the PR's edit to save_settings() replaced
'if k in _SETTINGS_ALLOWED_KEYS' with 'if k=="dashboard_plugins": continue' and
orphaned the whole validation body under the continue. Effects: (a) settings save
broken for every key except dashboard_plugins; (b) the allowlist security boundary
gone -> any client key (password_hash, signing_key_*) became settable. Restored the
guard + correct indentation; dashboard_plugins handled by the deep-merge above.
Verified in-process: language persists, password_hash/signing_key injection
rejected, dashboard_plugins still deep-merges.
Also (Opus SHOULD-FIX #3): validate plugin name against ^[a-z][a-z0-9_-]{0,63}$
so a manifest name like '../foo' can't make the URL-space ambiguous.
Regression tests added for both (the allowlist bug had ZERO coverage).
Both pre-release reviewers (Opus advisor + Codex regression gate) converged on
the same MUST-FIX:
- #3194: treating gateway_stale_stopped_state as 'configured' contradicted
#1944 (a stopped root gateway should read like 'not configured' so the
banner doesn't nag). Now ONLY stale-RUNNING metadata
(reason=gateway_stale_running_state or gateway_state=='running') flips
configured=True; stale-stopped falls through to bool(identity_map) like the
genuinely-unconfigured case. Updated the test accordingly + added a
stale-stopped no-regression test.
Opus follow-ups also applied:
- #2905: narrowed the populated-home markers to WebUI-only artifacts
(webui/, webui/sessions, webui/settings.json), dropping config.yaml/auth.json
so a long-time agent user installing WebUI fresh isn't wrongly diverted to
the legacy %USERPROFILE%\.hermes (auth.json predates #2897 there).
- profiles._resolve_base_hermes_home(): narrowed except Exception -> ImportError
so a real bug in the config helper still surfaces.
Adjacent suites green: #2840, #1879, gateway_status_agent_health (66 tests).
Two urgent breaking bugs that had no PR, combined into one hotfix.
#2905 (data-loss-class): v0.51.134 moved the Windows default Hermes home
from %USERPROFILE%\.hermes to %LOCALAPPDATA%\hermes (PR #2897) with no
migration, so upgrading Windows users opened the app to empty
sessions/pins/settings (data intact on disk, at an address the new build
no longer read). _platform_default_hermes_home() now prefers the populated
legacy home ONLY when the new location is not yet established —
non-destructive (no file moves) and self-healing on next launch.
profiles._resolve_base_hermes_home() delegates to the same config helper so
the active-profile pointer can never drift from STATE_DIR.
#3194: GET /api/gateway/status reported 'Gateway not configured' on a fresh
two-container Docker deploy because an alive=None + gateway_stale_running_state
health payload with an empty identity_map fell through to
configured=bool(identity_map)=False. The alive=None branch now treats a
payload carrying gateway metadata (gateway_state detail, or a stale-running/
stale-stopped reason) as configured.
Tests: +17 regression tests (11 for #2905 incl. full truth table + non-destructive
guard + POSIX no-op; 6 for #3194 incl. 5 no-regression guards). Full suite
7090 passed, 0 failed.
Closes#2905Closes#3194
Custom API aggregators (New API, One API, etc.) route requests using their
own naming conventions — bare names like ``deepseek-v4-flash`` or dot-separated
names like ``moonshotai.kimi-k2.5`` — rather than the OpenRouter-style
``vendor/model`` slash format that ``_heuristic_reasoning_efforts``'s prefix
list was written for.
Because none of these names match the slash-prefixed patterns, the function
returned ``[]``, hiding the reasoning effort selector in the WebUI even for
models that fully support thinking/reasoning.
Fix: add a secondary check in ``_heuristic_reasoning_efforts`` that strips an
optional dot-vendor prefix and matches the remaining model name against a list
of known thinking-capable bare-name prefixes (``deepseek-v4``, ``deepseek-r1``,
``kimi-k2``, ``qwen3``, ``claude-3/4``, ``o1/o3/o4``), plus a keyword catch
for model names containing ``thinking`` or ``reasoning``.
The slash-prefix path and all provider-specific fast paths (copilot, lmstudio,
models.dev metadata) are unchanged; this block only runs as a final fallback.
Regression tests added in ``tests/test_custom_provider_bare_model_reasoning.py``.
- Remove openai-codex special case that called github_model_reasoning_efforts()
- Codex now falls through to _models_dev_reasoning_efforts() (full efforts)
- GitHub/Copilot still use the GitHub helper (caps at high)
- Added regression tests for both behaviors
* Comment alongside _AUTH_FINGERPRINT_VOLATILE_KEYS notes client_secret
is treated as rotation-only on purpose, not a model-cache
differentiator (maintainer §Concerns 3).
* _write_auth docstring at tests/test_issue_t16551f61_auth_token_churn_
fingerprint.py:108 now describes actual behavior (writes + monkeypatch,
no sleep+restat) — Copilot r3302471228.
Refs #2242. PR #2964 review.
auth.json is rewritten by credential-pool/OAuth token refresh roughly every
14 minutes. _models_cache_source_fingerprint() hashed it via mtime/size
(#1699 _models_cache_file_fingerprint), so every token refresh churned the
fingerprint and the 24h /api/models cache was effectively dead -- the hot
GET /api/session?resolve_model=1 path paid a cold ~11.5s rebuild every few
minutes (RCA t_d127953d residual #2, t_16551f61).
Add _auth_store_semantic_fingerprint(): content-hash auth.json with a
DENY-list of known credential-rotation-only keys (access/refresh token,
expiry, per-credential status/telemetry, request_count, save updated_at)
stripped. Deny-list (not allow-list) is deliberate -- any unknown field, or
a real provider/endpoint/model-set change (active_provider, a new
credential_pool entry, base_url, source, label, auth_type, the providers{}
block, ...) stays in the fingerprint and still correctly busts the cache.
Conservative fallbacks: missing file -> marked; unreadable/corrupt ->
stat-based fallback (never less safe than pre-fix). config.yaml keeps the
cheap stat fingerprint (deliberate edits, no timer churn).
Bidirectional invariant regression test (non-tautological -- the
end-to-end churn test flips RED when the auth_json axis is reverted to
stat-based): token-only churn keeps fingerprint byte-identical AND keeps a
valid disk cache loadable; active_provider change / new credential_pool
entry / changed base_url each flip the fingerprint AND reject the stale
disk cache. Measured: 5/5 cold rebuilds per 5 refresh cycles -> 0/5.
Tests: 9 new pass; 28 adjacent (#1699/#1633/display-resolver) pass;
54 models_cache/fingerprint suite pass.
Resolve supported reasoning efforts per active model/provider and pass
that context through /api/reasoning so Composer and other non-configurable
models no longer show a misleading effort picker.
Co-authored-by: Cursor <cursoragent@cursor.com>
Ensure cursor/composer IDs always resolve via @cursor-acp:, carry the
visible picker selection into POST /api/session/new, persist model
changes before a session exists, and evict cached agents on model switch.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add cursor-acp to _PROVIDER_DISPLAY with label 'Cursor ACP'
- Add cursor-acp static model list to _PROVIDER_MODELS
- composer-2.5, composer-2, default, cursor-acp
Cherry-picked via 3-way apply (rebase had failed on static/index.html
conflict when applied via rebase commit chain; 3-way of the net delta
against stage HEAD applied cleanly).
Co-authored-by: mccxj <mccxj@github.users.noreply.github.com>
Bedrock was silently dropped from the picker because:
1. 'bedrock' absent from _PROVIDER_DISPLAY — group header fell back to
title-cased id; more critically the group fell to the else branch
2. 'bedrock' absent from _PROVIDER_MODELS — else branch has no
auto-detected models, so the group was never appended
3. Fallback env-var detection (hermes_cli unavailable) never checked
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
Fix:
- Add 'bedrock': 'AWS Bedrock' to _PROVIDER_DISPLAY
- Add static fallback model list to _PROVIDER_MODELS['bedrock'] with
global Anthropic Claude 4.x cross-region inference profile IDs;
live discovery via hermes_cli.models.provider_model_ids('bedrock')
is used first (existing _read_live_provider_model_ids machinery)
- Detect bedrock in env fallback path when both AWS_ACCESS_KEY_ID and
AWS_SECRET_ACCESS_KEY are present
Tests: tests/test_issue2720_bedrock_model_picker.py (5 new tests)
Per deep-review verdict SHIP-WITH-FIXES on PR #2636:
1. Profile-switch reconciliation: _refreshProfileSwitchBackground now re-fetches
/api/settings and re-applies hidden_tabs for the new profile. Without this,
Profile A's hidden-tabs choice stayed in effect under Profile B until the
user opened Settings → Appearance.
2. A11y: switched chips from role=button + aria-pressed to role=switch +
aria-checked. The pressed/not-pressed wording confused screen-reader users
because chip-off looks like the off state. Added role=group +
aria-labelledby on the container, and a :focus-visible style on the chips.
3. Server-side belt-and-suspenders: api/config.py now strips 'chat' and
'settings' from hidden_tabs at validation time, matching the client's apply-
time filter. A tampered POST can no longer persist the forbidden values.
3 new regression tests added (chat/settings rejection, profile-switch wiring,
chip a11y attributes).
Co-authored-by: FrancescoFarinola <francesco.farinola@example.com>
Custom providers that have a curated models: list in config.yaml
(e.g. ZenMux gateways) should show ONLY those configured models in
the picker dropdown, not the full /v1/models catalog.
Before this fix, _named_custom_groups unconditionally called
_read_custom_endpoint_models() which would pull hundreds of models
from aggregator gateways and overwrite the user's curated list.
Now the build checks if the custom_provider entry has a non-empty
models dict/list in config.yaml — if so, it skips the live fetch
and uses only the configured models (same behavior as hermes-agent
model_switch.py Section 4 patch).
Closes: configure-model-list-should-be-authoritative
When an anonymous custom endpoint (bare base_url, not a named custom_providers[] entry) fails its /v1/models probe, the provider group was silently dropped from the model picker entirely. This made the endpoint unusable even when /v1/chat/completions would work fine.
The fix adds an elif branch: if pid == 'custom', a cfg_base_url is configured, but no models were returned by the probe, the group is still added with an empty model list. Users can then select the Custom group and type a model ID manually in the picker's free-form input.
Closes#2542.
Backend (api/config.py):
- resolve_model_provider(): check custom_providers for prefix match
BEFORE the config_base_url branch. Previously, providers with a
base_url set (e.g. deepseek) would catch all slash-delimited model
ids and return the config provider, preventing custom provider
routing.
- get_available_models(): include model aliases in response so the
frontend can resolve them on /model commands.
Frontend (static/commands.js):
- cmdModel(): resolve aliases by fetching /api/models before fuzzy
matching the dropdown.
- Add bare-model fallback when the alias resolves to a slash-delimited
provider/model id (e.g. "deepseek/deepseek-v4-flash").
- Add cross-provider fallback: when the model is from a custom provider
not in the active provider dropdown, call /api/session/update directly
with the provider/model id and provider override.
The get_available_models() function only handled dict-format models
(`{model_id: {}}`) for custom_providers entries, silently dropping
models specified as YAML lists (`[model1, model2]`) or list of dicts
(`[{id: ..., label: ...}]`).
This caused users who define their custom providers with list-format
model declarations to see zero or incomplete model entries in both
Settings → Preferences → Default Model dropdown and the chat
interface model picker.
The fix adds an `elif isinstance(_cp_models_dict, list)` branch with
support for three list sub-formats:
- Plain string list: `models: [m1, m2]`
- Dict list: `models: [{id: m1, label: ...}]`
- Mixed: `models: [m1, {id: m2}]`
Refs: hermes-agent issue where YAML list models were invisible
Replace the earlier frontend-reset approach with a backend side-channel
approach that preserves the queue (event, data) tuple shape.
Problem (Opus catch):
- Live SSE frames emitted by _sse() in api/streaming.py:2296 carried no
'id:' field. Only journal-replay frames (via _sse_with_id) emitted IDs.
- Frontend's _lastRunJournalSeq cursor stayed at 0 during live streaming.
- Mid-stream error → reconnect-to-replay arrived with after_seq=0.
- Server replayed every journaled event from seq 1.
- assistantText (closure-scoped) had accumulated all live tokens already
→ double-rendered output.
Fix:
- api/config.py: STREAM_LAST_EVENT_ID: dict = {} module-level dict.
- api/streaming.py put(): capture journal event_id, write to
STREAM_LAST_EVENT_ID[stream_id]. Keep queue tuple as (event, data).
- api/routes.py _handle_sse_stream: read STREAM_LAST_EVENT_ID[stream_id]
at emit time, use _sse_with_id when set.
- api/streaming.py finally block: pop STREAM_LAST_EVENT_ID for cleanup.
Why side-channel instead of 3-tuple:
- Earlier attempt (queue tuple → (event, data, event_id)) broke 4 existing
tests: test_cancel_interrupt, test_sprint42, test_sprint51,
test_issue1857_usage_overwrite. These all unpack 'event, data = q.get()'.
- Frontend-reset approach (reset assistantText before replay) broke 3
other tests: test_smooth_text_fade, test_streaming_markdown,
test_streaming_race_fix. _wireSSE must NOT reset accumulators because
legacy reconnect doesn't replay events; only journal-replay does.
Side-channel preserves both invariants:
- Queue contract stays (event, data) — legacy consumers unbroken.
- Frontend accumulators stay alive on _wireSSE — legacy reconnect unbroken.
- Live SSE emits 'id:' so the journal cursor advances correctly.
6 regression tests added in test_stage364_opus_live_sse_event_id.py.
1 existing test (test_run_journal_streaming_static.test_streaming_journals_sse_events_before_queue_delivery) updated to be tuple-shape-agnostic.
Test results:
- Full pytest: 5713 passed, 10 skipped, 1 xfailed, 2 xpassed, 0 failed
- Previously-failing 5 tests: ALL PASS
- 6 new regression tests: ALL PASS
fix(config): preserve nvidia/ prefix on NVIDIA NIM (closes#2177)
Self-built. nesquena APPROVED with extensive end-to-end trace including
cross-tool agent CLI verification and 12-shape behavioural harness.
Move the `_PORTAL_PROVIDERS` guard in `resolve_model_provider()` to run
BEFORE the `prefix == config_provider` strip branch. The guard was added
for NVIDIA (along with the Nous portal cases in #854 / #894) but was
placed after the strip, so it never fired when `config_provider == "nvidia"`
and the model id started with `nvidia/`.
For `model_id="nvidia/nemotron-3-super-120b-a12b"`,
`config_provider="nvidia"`:
- prefix = "nvidia", bare = "nemotron-3-super-120b-a12b"
- prefix == config_provider → True → strip branch returned bare name
- `_PORTAL_PROVIDERS` guard never reached
- bare "nemotron-3-super-120b-a12b" sent to NVIDIA NIM → HTTP 404
NIM requires the full namespaced path. The fix moves the portal guard
to run first, so all portal providers (Nous, OpenCode-Zen, OpenCode-Go,
NVIDIA NIM) always preserve the full `provider/model` id regardless of
whether the prefix happens to equal the provider name.
This also closes a latent symmetric bug for the Nous case if a
`nous/<model>` id ever existed in the catalog.
Test plan:
- New `tests/test_issue2177_nvidia_prefix_preservation.py` covers:
- nvidia/nemotron-... under nvidia (the reported case)
- cross-namespace qwen/ and meta/ under nvidia (regression pin)
- every static nvidia model in `_PROVIDER_MODELS` resolves to itself
- latent nous/<model> under nous (structural ordering pin)
- non-portal providers (anthropic) still strip — fix doesn't over-correct
- Existing portal-routing suites (test_nous_portal_routing.py,
test_issue895_894_nous_prefix.py) continue to pass.
- Full test suite: 5320 passed, 4 skipped, 3 xpassed.
Reported on Discord by @vishnu (Nathan forwarded as #2177).
When a provider's 'models' config contains dicts (e.g. {"id": "x", "label": "y"})
instead of plain strings, _apply_provider_prefix() crashes with:
AttributeError: 'dict' object has no attribute 'startswith'
This happens because the list comprehension at line 3505 passes the raw dict
as the model ID. The fix extracts 'id' and 'label' from dict entries while
keeping string entries as-is.
Fixes the /api/models and /api/onboarding/status 500 errors.
CI on Python 3.13 (clean editable install, no hermes_cli package) was still
failing the 3 lmstudio tests after the first fix attempt. Root cause: the
outer try/except in the lmstudio branch was catching ImportError from
`from hermes_cli.models import provider_model_ids`, hijacking the whole
branch and silently skipping the urlopen fallback.
Restructured into two independent tiers:
1. hermes_cli lookup in its own try/except — ImportError logs at DEBUG
and continues with lm_ids=[].
2. urlopen fallback runs unconditionally when lm_ids is empty, including
after hermes_cli import failure.
New regression test `test_lmstudio_fallback_works_when_hermes_cli_unavailable`
explicitly blocks hermes_cli via sys.meta_path and verifies the lmstudio
group still populates from the urlopen fallback. Without this test, the
CI-vs-local divergence (local env had hermes_cli installed, CI didn't)
would keep slipping through.
All 12 lmstudio-related tests pass, including the 3 #1527 tests that
broke on stage-337.
PR #1970 added a dedicated `elif pid == "lmstudio":` branch in
`get_available_models()` that fetches the live /v1/models list when the
hermes_cli helper doesn't have ids cached. The fallback path inside that
branch only looked at `cfg["providers"]["lmstudio"]["base_url"]`, missing
the historical config shape where the URL lives under `cfg["model"]`:
model:
provider: lmstudio
base_url: http://192.168.1.22:1234/v1 ← here, not under providers.lmstudio
providers:
lmstudio:
api_key: local-key
3 pre-existing tests in tests/test_issue1527_lmstudio_base_url_classification
broke on stage-337 because of this — they passed on master, failed after
the PR #1970 merge.
The simpler fix is to enhance the already-introduced `_get_provider_base_url()`
helper so it falls back to `cfg["model"]["base_url"]` when
`cfg["model"]["provider"] == provider_id`, then use the helper inside the
lmstudio branch instead of a direct lookup. This keeps the previous
behaviour (where the generic configured-provider branch handled lmstudio
via the model block) while preserving PR #1970's live-discovery additions.
Belt-and-suspenders: `_get_provider_base_url()` explicitly does NOT inherit
model.base_url for providers other than the active one — if a user's config
says `model.provider: anthropic` and they have `providers.openai` configured
without a base_url, openai must still resolve to None (use SDK default),
not to the anthropic proxy URL.
6 new regression tests in tests/test_pr1970_lmstudio_base_url_fallback.py
lock the two-location lookup, the precedence rule (explicit providers entry
wins over model fallback), trailing-slash stripping, and the negative case
(model.base_url MUST NOT leak to non-active providers).
All 51 tests in the existing model-resolver + custom-provider banks still
pass.
Caught by maintainer review on stage-337 (full pytest with the new network
isolation in place surfaced the regression that the fork-CI mock-server path
would have hidden).
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'.
- api/config.py: resolve merge conflict, keep both _custom_slug_rest_looks_like_host_port
and new _get_provider_base_url helper. Custom providers now return their configured
base_url in resolve_model_provider(). Add 'Configured' badge for explicitly configured
providers in the models dropdown. Detect LM Studio via LM_API_KEY+LM_BASE_URL env vars.
Fetch live loaded models from LM Studio with fallback to direct HTTP requests.
- api/providers.py: fetch live LM Studio model list via hermes_cli for the providers card.
- static/style.css: add purple 'Configured' badge style.
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.
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.
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.
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.
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).
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.
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>
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>
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.
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).
#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.
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.
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.
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.
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
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
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]>