2026-03-31 03:40:19 +00:00
"""
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
Hermes Web UI - - Shared configuration , constants , and global state .
2026-03-31 03:40:19 +00:00
Imported by all other api / * modules and by server . py .
Discovery order for all paths :
1. Explicit environment variable
2. Filesystem heuristics ( sibling checkout , parent dir , common install locations )
3. Hardened defaults relative to $ HOME
4. Fail loudly with a human - readable fix - it message if required modules are missing
"""
2026-04-12 07:11:41 +00:00
2026-03-31 03:40:19 +00:00
import collections
2026-04-21 00:33:03 +00:00
import copy
2026-05-17 05:23:24 +00:00
import hashlib
2026-03-31 03:40:19 +00:00
import json
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
import logging
2026-06-29 05:59:51 +00:00
import math
2026-03-31 03:40:19 +00:00
import os
2026-05-04 05:43:11 +00:00
import queue
2026-05-11 05:48:46 +00:00
import re
2026-06-28 22:29:37 +00:00
import socket
2026-03-31 03:40:19 +00:00
import sys
import threading
import time
import traceback
2026-06-14 22:46:35 +00:00
import urllib . error
import urllib . request
2026-03-31 03:40:19 +00:00
import uuid
from pathlib import Path
2026-06-20 23:52:52 +00:00
from typing import Any
2026-03-31 03:40:19 +00:00
from urllib . parse import parse_qs , urlparse
# ── Basic layout ──────────────────────────────────────────────────────────────
2026-06-01 02:34:55 +00:00
import api . paths as _paths
2026-06-05 18:35:26 +00:00
from api . plugin_providers import (
effective_provider_display_name as _effective_provider_display_name ,
is_plugin_model_provider as _is_plugin_model_provider ,
)
2026-05-31 18:54:23 +00:00
2026-06-01 02:34:55 +00:00
HOME = _paths . HOME
_hermes_home_has_webui_state = _paths . _hermes_home_has_webui_state
_platform_default_hermes_home = _paths . _platform_default_hermes_home
2026-05-31 18:46:37 +00:00
2026-06-01 02:34:55 +00:00
# REPO_ROOT is the directory that contains this file's parent (api/ -> repo root)
REPO_ROOT = Path ( __file__ ) . parent . parent . resolve ( )
2026-05-25 00:41:26 +00:00
2026-03-31 03:40:19 +00:00
# ── Network config (env-overridable) ─────────────────────────────────────────
2026-04-12 07:11:41 +00:00
HOST = os . getenv ( " HERMES_WEBUI_HOST " , " 127.0.0.1 " )
PORT = int ( os . getenv ( " HERMES_WEBUI_PORT " , " 8787 " ) )
2026-03-31 03:40:19 +00:00
Release v0.51.256 — Release HX (stage-r4) (#3596)
## 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>
2026-06-04 19:28:06 +00:00
def _env_int ( name : str , default : int , * , minimum : int = 1 ) - > int :
""" Read a positive int from the environment, falling back on bad input.
Used for operator - tunable memory caps ( issue #3506) so large installs can
shrink the agent / session caches without editing source . A missing , empty ,
non - numeric , or below - ` ` minimum ` ` value falls back to ` ` default ` ` so a typo
can never disable a cache bound entirely .
"""
raw = os . getenv ( name )
if raw is None or not str ( raw ) . strip ( ) :
return default
try :
value = int ( str ( raw ) . strip ( ) )
except ( TypeError , ValueError ) :
return default
return value if value > = minimum else default
2026-04-10 01:08:29 +00:00
# ── TLS/HTTPS config (optional, env-overridable) ────────────────────────────
2026-04-12 07:11:41 +00:00
TLS_CERT = os . getenv ( " HERMES_WEBUI_TLS_CERT " , " " ) . strip ( ) or None
TLS_KEY = os . getenv ( " HERMES_WEBUI_TLS_KEY " , " " ) . strip ( ) or None
2026-04-10 01:08:29 +00:00
TLS_ENABLED = TLS_CERT is not None and TLS_KEY is not None
2026-03-31 03:40:19 +00:00
# ── State directory (env-overridable, never inside repo) ──────────────────────
2026-05-25 00:41:26 +00:00
_DEFAULT_HERMES_HOME = _platform_default_hermes_home ( )
2026-06-19 23:26:05 +00:00
_DEFAULT_STATE_HOME = Path ( os . getenv ( " HERMES_HOME " ) or _DEFAULT_HERMES_HOME ) . expanduser ( )
2026-05-25 00:41:26 +00:00
2026-04-12 07:11:41 +00:00
STATE_DIR = (
2026-06-19 23:26:05 +00:00
Path ( os . getenv ( " HERMES_WEBUI_STATE_DIR " , str ( _DEFAULT_STATE_HOME / " webui " ) ) )
2026-04-12 07:11:41 +00:00
. expanduser ( )
. resolve ( )
)
SESSION_DIR = STATE_DIR / " sessions "
WORKSPACES_FILE = STATE_DIR / " workspaces.json "
SESSION_INDEX_FILE = SESSION_DIR / " _index.json "
SETTINGS_FILE = STATE_DIR / " settings.json "
LAST_WORKSPACE_FILE = STATE_DIR / " last_workspace.txt "
PROJECTS_FILE = STATE_DIR / " projects.json "
security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)
- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists
* fix: handle ConnectionResetError gracefully and add debug logging
- Add QuietHTTPServer class to suppress noisy connection reset errors
caused by clients disconnecting abruptly (fixes log spam from
'ConnectionResetError: [Errno 54] Connection reset by peer')
- Replace silent 'pass' statements with logger.debug() calls across
api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
and api/onboarding.py for better observability during troubleshooting
- All tests pass (25 passed in test_regressions.py)
* chore: add debug logging to profiles and routes modules
- Replace silent 'pass' statements with logger.debug() calls in
api/profiles.py for better error visibility during profile switching
and module patching
- Add logger initialization to api/routes.py
* security: fix B110 bare except/pass issues (bandit security scan)
- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully
* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)
---------
Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 18:11:56 +00:00
logger = logging . getLogger ( __name__ )
2026-05-27 16:19:42 +00:00
# Keep custom provider /v1/models probes below the frontend's generic request
# timeout even when one upstream is slow or unreachable. The models cache rebuild
# path probes configured custom endpoints serially, so each provider needs a
# short hard cap and graceful degradation.
CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS = 5.0
2026-03-31 03:40:19 +00:00
2026-05-15 02:20:05 +00:00
def _env_mb_bytes ( name : str , default_mb : int ) - > int :
""" Parse an optional megabyte environment variable into bytes.
Accepts values like ` ` 200 ` ` , ` ` 200 MB ` ` , or ` ` 200 MiB ` ` . Invalid or
non - positive values fall back to the provided default .
"""
raw = os . getenv ( name , " " ) . strip ( )
if not raw :
return default_mb * 1024 * 1024
m = re . match ( r " ^( \ d+) \ s*(?:m|mb|mib)?$ " , raw , re . IGNORECASE )
if not m :
logger . warning (
" Invalid %s = %r ; expected a positive integer in MB. Falling back to %s MB. " ,
name ,
raw ,
default_mb ,
)
return default_mb * 1024 * 1024
value_mb = int ( m . group ( 1 ) )
if value_mb < = 0 :
logger . warning (
" Invalid %s = %r ; expected a value greater than zero. Falling back to %s MB. " ,
name ,
raw ,
default_mb ,
)
return default_mb * 1024 * 1024
return value_mb * 1024 * 1024
2026-03-31 03:40:19 +00:00
# ── Hermes agent directory discovery ─────────────────────────────────────────
def _discover_agent_dir ( ) - > Path :
"""
Locate the hermes - agent checkout using a multi - strategy search .
Priority :
1. HERMES_WEBUI_AGENT_DIR env var - - explicit override always wins
2. HERMES_HOME / hermes - agent - - e . g . ~ / . hermes / hermes - agent
3. Sibling of this repo - - . . / hermes - agent
4. Parent of this repo - - . . / . . / hermes - agent ( nested layout )
5. Common install paths - - ~ / . hermes / hermes - agent ( again as fallback )
6. HOME / hermes - agent - - ~ / hermes - agent ( simple flat layout )
"""
2026-06-26 16:32:55 +00:00
explicit_override = os . getenv ( " HERMES_WEBUI_AGENT_DIR " )
if explicit_override :
explicit_path = Path ( explicit_override ) . expanduser ( ) . resolve ( )
if explicit_path . exists ( ) and _looks_like_agent_source_root ( explicit_path ) :
return explicit_path
2026-03-31 03:40:19 +00:00
2026-06-26 16:32:55 +00:00
candidates = [ ]
2026-03-31 03:40:19 +00:00
# 2. HERMES_HOME / hermes-agent
2026-05-25 00:41:26 +00:00
hermes_home = os . getenv ( " HERMES_HOME " , str ( _DEFAULT_HERMES_HOME ) )
2026-04-12 07:11:41 +00:00
candidates . append ( Path ( hermes_home ) . expanduser ( ) / " hermes-agent " )
2026-03-31 03:40:19 +00:00
# 3. Sibling: <repo-root>/../hermes-agent
2026-04-12 07:11:41 +00:00
candidates . append ( REPO_ROOT . parent / " hermes-agent " )
2026-03-31 03:40:19 +00:00
# 4. Parent is the agent repo itself (repo cloned inside hermes-agent/)
2026-06-26 15:03:04 +00:00
if _looks_like_agent_source_root ( REPO_ROOT . parent ) :
2026-03-31 03:40:19 +00:00
candidates . append ( REPO_ROOT . parent )
# 5. ~/.hermes/hermes-agent (explicit common path)
2026-05-25 00:41:26 +00:00
candidates . append ( _DEFAULT_HERMES_HOME / " hermes-agent " )
2026-03-31 03:40:19 +00:00
# 6. ~/hermes-agent
2026-04-12 07:11:41 +00:00
candidates . append ( HOME / " hermes-agent " )
2026-03-31 03:40:19 +00:00
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
# 7. XDG_DATA_HOME / hermes-agent (e.g. ~/.local/share/hermes-agent)
xdg_data = Path ( os . getenv ( " XDG_DATA_HOME " , str ( HOME / " .local " / " share " ) ) )
candidates . append ( xdg_data . expanduser ( ) / " hermes-agent " )
# 8. System-wide install paths (e.g. /opt/hermes-agent, /usr/local/hermes-agent)
for sys_prefix in ( " /opt " , " /usr/local " , " /usr/local/share " ) :
candidates . append ( Path ( sys_prefix ) / " hermes-agent " )
2026-06-26 16:32:55 +00:00
# Prefer real source checkouts before pip-style roots so lookalikes cannot preempt them.
2026-03-31 03:40:19 +00:00
for path in candidates :
2026-06-26 16:32:55 +00:00
if path . exists ( ) and ( path / " run_agent.py " ) . exists ( ) :
return path . resolve ( )
for path in candidates :
if path . exists ( ) and _looks_like_pip_style_agent_source_root ( path ) :
2026-03-31 03:40:19 +00:00
return path . resolve ( )
return None
2026-06-26 13:43:37 +00:00
def _looks_like_agent_source_root ( path : Path ) - > bool :
""" Return True when a directory resembles a hermes-agent source root. """
if ( path / " run_agent.py " ) . exists ( ) :
return True
2026-06-26 16:32:55 +00:00
return _looks_like_pip_style_agent_source_root ( path )
def _looks_like_pip_style_agent_source_root ( path : Path ) - > bool :
""" Return True for pip-style agent roots with a real agent package signal. """
2026-06-26 13:43:37 +00:00
if not ( path / " cron " / " jobs.py " ) . exists ( ) :
return False
2026-06-26 16:32:55 +00:00
if ( path / " hermes " ) . exists ( ) :
return True
hermes_cli_dir = path / " hermes_cli "
return (
( hermes_cli_dir / " __init__.py " ) . exists ( )
or ( hermes_cli_dir / " main.py " ) . exists ( )
)
2026-06-26 13:43:37 +00:00
2026-03-31 03:40:19 +00:00
def _discover_python ( agent_dir : Path ) - > str :
"""
Locate a Python executable that has the Hermes agent dependencies installed .
Priority :
1. HERMES_WEBUI_PYTHON env var
2. Agent venv at < agent_dir > / venv / bin / python
3. Local . venv inside this repo
4. System python3
"""
2026-04-12 07:11:41 +00:00
if os . getenv ( " HERMES_WEBUI_PYTHON " ) :
return os . getenv ( " HERMES_WEBUI_PYTHON " )
2026-03-31 03:40:19 +00:00
if agent_dir :
2026-04-12 07:11:41 +00:00
venv_py = agent_dir / " venv " / " bin " / " python "
2026-03-31 03:40:19 +00:00
if venv_py . exists ( ) :
return str ( venv_py )
2026-04-24 17:45:23 +00:00
venv_py = agent_dir / " .venv " / " bin " / " python "
if venv_py . exists ( ) :
return str ( venv_py )
2026-03-31 03:40:19 +00:00
# Windows layout
2026-04-12 07:11:41 +00:00
venv_py_win = agent_dir / " venv " / " Scripts " / " python.exe "
2026-03-31 03:40:19 +00:00
if venv_py_win . exists ( ) :
return str ( venv_py_win )
2026-04-24 17:45:23 +00:00
venv_py_win = agent_dir / " .venv " / " Scripts " / " python.exe "
if venv_py_win . exists ( ) :
return str ( venv_py_win )
2026-03-31 03:40:19 +00:00
# Local .venv inside this repo
2026-06-04 17:50:34 +00:00
for subdir , binary in ( ( " bin " , " python " ) , ( " Scripts " , " python.exe " ) ) :
local_venv = REPO_ROOT / " .venv " / subdir / binary
if local_venv . exists ( ) :
return str ( local_venv )
2026-03-31 03:40:19 +00:00
# Fall back to system python3
import shutil
2026-04-12 07:11:41 +00:00
for name in ( " python3 " , " python " ) :
2026-03-31 03:40:19 +00:00
found = shutil . which ( name )
if found :
return found
2026-04-12 07:11:41 +00:00
return " python3 "
2026-03-31 03:40:19 +00:00
# Run discovery
_AGENT_DIR = _discover_agent_dir ( )
PYTHON_EXE = _discover_python ( _AGENT_DIR )
2026-04-04 17:59:33 +00:00
# ── Inject agent dir into sys.path so Hermes modules are importable ──────────
# When users (or CI builds) run `pip install --target .` or
# `pip install -t .` inside the hermes-agent checkout, third-party
# package directories (openai/, pydantic/, requests/, etc.) end up
# alongside real Hermes source files. Putting _AGENT_DIR at the
# FRONT of sys.path means Python resolves `import pydantic` from that
# local directory — which breaks whenever the host platform differs
# from the container (e.g. macOS .so files inside a Linux image).
#
# Fix: insert _AGENT_DIR at the END of sys.path. Python searches
# entries in order, so site-packages resolves pip packages correctly,
# and Hermes-specific modules (run_agent, hermes/, etc.) still
# resolve because they do not exist in site-packages.
2026-03-31 03:40:19 +00:00
if _AGENT_DIR is not None :
if str ( _AGENT_DIR ) not in sys . path :
2026-04-04 17:59:33 +00:00
sys . path . append ( str ( _AGENT_DIR ) )
2026-03-31 03:40:19 +00:00
_HERMES_FOUND = True
else :
_HERMES_FOUND = False
2026-06-22 20:18:01 +00:00
# ── Thread-local env context ─────────────────────────────────────────────────
# Defined BEFORE the config-file section because _expand_env_vars() (below) calls
# _thread_local_env_value() and the import-time reload_config() runs during module
# load — a forward reference here would NameError on any startup config.yaml that
# uses a ${VAR} reference. Depends only on os + threading (both imported above).
_thread_ctx = threading . local ( )
def _thread_local_env_value ( name : str , default : str = " " ) - > str :
""" Return thread-local profile env first, then process env, for provider reads. """
env_name = str ( name or " " ) . strip ( )
if not env_name :
return default or " "
thread_env = getattr ( _thread_ctx , " env " , { } )
if isinstance ( thread_env , dict ) and env_name in thread_env :
thread_value = thread_env . get ( env_name )
if thread_value is None :
return default or " "
return str ( thread_value )
if bool ( getattr ( _thread_ctx , " block_process_env_fallback " , False ) ) :
return default or " "
return str ( os . getenv ( env_name , default or " " ) )
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
# ── Config file (reloadable -- supports profile switching) ──────────────────
2026-06-07 00:24:18 +00:00
def _expand_env_vars ( obj ) :
2026-06-22 19:49:44 +00:00
""" Recursively expand $ {VAR} references in config values.
Uses the thread - local - first profile env lookup ( _thread_local_env_value ) so a
$ { VAR } reference in a profile ' s config.yaml resolves to that profile ' s value ,
and — critically — does NOT fall back to the server process os . environ when a
profile - scoped readonly / background scope set block_process_env_fallback . The
raw ( unexpanded ) dict is what gets cached ; this expansion re - runs on every
read against the current thread ' s scope, so a cross-profile credential
( e . g . config api_key : $ { ANTHROPIC_TOKEN } ) can ' t be reconstructed from the
server process env for a named profile that has no such value ( #3961)."""
2026-06-07 00:24:18 +00:00
if isinstance ( obj , str ) :
2026-06-22 19:49:44 +00:00
return re . sub (
r " \ $ { ([^}]+)} " ,
lambda m : _thread_local_env_value ( m . group ( 1 ) , m . group ( 0 ) ) ,
obj ,
)
2026-06-07 00:24:18 +00:00
if isinstance ( obj , dict ) :
return { k : _expand_env_vars ( v ) for k , v in obj . items ( ) }
if isinstance ( obj , list ) :
return [ _expand_env_vars ( item ) for item in obj ]
return obj
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
_cfg_cache = { }
_cfg_lock = threading . Lock ( )
2026-04-16 18:55:18 +00:00
_cfg_mtime : float = 0.0 # last known mtime of config.yaml; 0 = never loaded
2026-05-06 01:39:11 +00:00
_cfg_path : Path | None = None # active config.yaml path for the disk-loaded cache
_cfg_fingerprint : str | None = None # serialized snapshot from the last disk load
def _fingerprint_config ( data : dict ) - > str :
""" Return a stable fingerprint for config dictionaries.
A few tests and legacy call sites still mutate ` ` cfg ` ` directly for
in - memory overrides . Path - aware reloads should not immediately discard
those overrides just because the active profile path differs from the last
disk load , but an unchanged disk - loaded cache must still reload on profile
switches .
"""
try :
return json . dumps ( data , sort_keys = True , separators = ( " , " , " : " ) , default = str )
except Exception :
return repr ( data )
def _cfg_has_in_memory_overrides ( ) - > bool :
2026-05-06 08:10:08 +00:00
""" True when cfg was changed after the last successful reload_config().
Detects two override shapes :
1. ` ` _cfg_cache ` ` was mutated in place ( fingerprint differs ) .
2. ` ` cfg ` ` ( the module attribute ) was rebound to a different dict —
e . g . ` ` monkeypatch . setattr ( config , " cfg " , { . . . } ) ` ` in tests . The
alias - with - the - cache pattern at module load means this is a common
test - isolation override , and silently reloading from disk over it
( the v0 .51 .7 path - aware reload regression ) breaks any test that
relies on the override .
"""
if _cfg_fingerprint is not None and _fingerprint_config ( _cfg_cache ) != _cfg_fingerprint :
return True
# Module attribute rebound away from _cfg_cache by a test or runtime caller.
try :
return cfg is not _cfg_cache
except NameError :
# cfg not yet defined (during initial reload_config() at import time).
return False
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
2026-04-12 07:11:41 +00:00
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
def _get_config_path ( ) - > Path :
""" Return config.yaml path for the active profile. """
2026-04-12 07:11:41 +00:00
env_override = os . getenv ( " HERMES_CONFIG_PATH " )
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
if env_override :
return Path ( env_override ) . expanduser ( )
try :
from api . profiles import get_active_hermes_home
2026-04-12 07:11:41 +00:00
return get_active_hermes_home ( ) / " config.yaml "
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
except ImportError :
2026-05-25 00:41:26 +00:00
return _DEFAULT_HERMES_HOME / " config.yaml "
2026-04-12 07:11:41 +00:00
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
2026-05-04 21:05:49 +00:00
_WEBUI_SESSION_SAVE_MODES = { " deferred " , " eager " }
_DEFAULT_WEBUI_SESSION_SAVE_MODE = " deferred "
2026-06-07 01:16:07 +00:00
_DEFAULT_EXPERIMENTAL_CONFIG = {
# Dormant first slice for the unified SessionDB migration. Runtime WebUI
# session call sites must continue using the existing JSON paths unless a
# later PR deliberately enables and wires this flag.
" unified_session_db " : False ,
}
2026-06-19 22:26:30 +00:00
_DEFAULT_AGENT_PERSONALITIES = {
# Mirrors the Hermes Agent CLI built-ins so WebUI's config-derived
# /personality path is not empty for fresh profiles.
" helpful " : " You are a helpful, friendly AI assistant. " ,
" concise " : " You are a concise assistant. Keep responses brief and to the point. " ,
" technical " : " You are a technical expert. Provide detailed, accurate technical information. " ,
" creative " : " You are a creative assistant. Think outside the box and offer innovative solutions. " ,
" teacher " : " You are a patient teacher. Explain concepts clearly with examples. " ,
" kawaii " : " You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)ノ " ,
" catgirl " : " You are Neko-chan, an anime catgirl AI assistant, nya~! Add ' nya ' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~! " ,
" pirate " : " Arrr! Ye be talkin ' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin ' to be plundered! Yo ho ho! " ,
" shakespeare " : " Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks? " ,
" surfer " : " Duuude! You ' re chatting with the chillest AI on the web, bro! Everything ' s gonna be totally rad. I ' ll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga! " ,
" noir " : " The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone ' s got something to hide. What ' s your story, pal? " ,
" uwu " : " hewwo! i ' m your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what ' s this? wet me take a wook! i pwomise to be vewy hewpful >w< " ,
" philosopher " : " Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the ' how ' but the ' why ' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself. " ,
" hype " : " YOOO LET ' S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we ' re gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET ' S DO THIS! " ,
}
2026-06-07 01:16:07 +00:00
def _apply_config_defaults ( config_data : dict ) - > None :
""" Populate documented default-only config keys in-place. """
2026-06-19 22:26:30 +00:00
agent_cfg = config_data . get ( " agent " )
if not isinstance ( agent_cfg , dict ) :
agent_cfg = { }
config_data [ " agent " ] = agent_cfg
personalities = agent_cfg . get ( " personalities " )
if isinstance ( personalities , dict ) :
merged = copy . deepcopy ( _DEFAULT_AGENT_PERSONALITIES )
merged . update ( copy . deepcopy ( personalities ) )
agent_cfg [ " personalities " ] = merged
else :
# Keep behavior aligned with CLI loader defaults: if personalities are
# absent or malformed, replace the section entirely with built-ins.
agent_cfg [ " personalities " ] = copy . deepcopy ( _DEFAULT_AGENT_PERSONALITIES )
2026-06-07 01:16:07 +00:00
experimental = config_data . get ( " experimental " )
if not isinstance ( experimental , dict ) :
experimental = { }
config_data [ " experimental " ] = experimental
for key , value in _DEFAULT_EXPERIMENTAL_CONFIG . items ( ) :
experimental . setdefault ( key , value )
2026-05-04 21:05:49 +00:00
2026-06-29 20:20:07 +00:00
def reload_config_if_stale ( ) - > None :
""" Refresh config.yaml once for concurrent stale read paths. """
with _cfg_lock :
try :
config_path = _get_config_path ( )
current_mtime = config_path . stat ( ) . st_mtime
except OSError :
current_mtime = 0.0
cache_stale = current_mtime != _cfg_mtime or _cfg_path != config_path
if not _cfg_cache or ( cache_stale and not _cfg_has_in_memory_overrides ( ) ) :
_refresh_config_cache ( config_path )
2026-05-04 21:05:49 +00:00
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
def get_config ( ) - > dict :
""" Return the cached config dict, loading from disk if needed. """
2026-05-06 01:39:11 +00:00
config_path = _get_config_path ( )
try :
current_mtime = config_path . stat ( ) . st_mtime
except OSError :
current_mtime = 0.0
cache_stale = current_mtime != _cfg_mtime or _cfg_path != config_path
if not _cfg_cache or ( cache_stale and not _cfg_has_in_memory_overrides ( ) ) :
2026-06-29 20:20:07 +00:00
reload_config_if_stale ( )
2026-05-06 08:10:08 +00:00
# When a test (or runtime caller) has rebound ``cfg`` to a different dict
# via monkeypatch.setattr(config, "cfg", ...), return that override rather
# than the underlying _cfg_cache. Without this branch, get_config() would
# silently bypass the override even though _cfg_has_in_memory_overrides()
# correctly suppressed the reload.
try :
if cfg is not _cfg_cache :
return cfg
except NameError :
pass
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
return _cfg_cache
2026-04-12 07:11:41 +00:00
2026-05-04 21:05:49 +00:00
def get_webui_session_save_mode ( config_data : dict | None = None ) - > str :
""" Return the validated first-turn session persistence mode.
` ` deferred ` ` preserves the current first - turn sidecar behaviour : persist
pending_user_message / runtime fields before streaming , then merge the turn
after the agent finishes . ` ` eager ` ` additionally checkpoints the current
user turn into ` ` messages ` ` before launching the agent thread . Unknown
values fail closed to ` ` deferred ` ` so a typo never reintroduces eager disk
writes unexpectedly .
"""
active_cfg = config_data if isinstance ( config_data , dict ) else cfg
webui_cfg = active_cfg . get ( " webui " , { } ) if isinstance ( active_cfg , dict ) else { }
if not isinstance ( webui_cfg , dict ) :
return _DEFAULT_WEBUI_SESSION_SAVE_MODE
mode = webui_cfg . get ( " session_save_mode " , _DEFAULT_WEBUI_SESSION_SAVE_MODE )
if isinstance ( mode , str ) :
normalized = mode . strip ( ) . lower ( )
if normalized in _WEBUI_SESSION_SAVE_MODES :
return normalized
return _DEFAULT_WEBUI_SESSION_SAVE_MODE
2026-06-07 01:16:07 +00:00
def is_unified_session_db_enabled ( config_data : dict | None = None ) - > bool :
""" Return the dormant unified-session-db feature flag.
The default is intentionally false so adding the JSON adapter cannot change
runtime persistence until a later migration PR switches call sites .
"""
active_cfg = config_data if isinstance ( config_data , dict ) else cfg
experimental = active_cfg . get ( " experimental " , { } ) if isinstance ( active_cfg , dict ) else { }
if not isinstance ( experimental , dict ) :
return False
return experimental . get ( " unified_session_db " ) is True
2026-06-29 20:03:50 +00:00
def _refresh_config_cache ( config_path : Path | None = None ) - > None :
""" Refresh _cfg_cache for ``config_path``.
Callers must hold _cfg_lock when invoking this helper because it mutates
shared state .
"""
2026-05-06 01:39:11 +00:00
global _cfg_mtime , _cfg_path , _cfg_fingerprint
2026-06-29 20:03:50 +00:00
if config_path is None :
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
config_path = _get_config_path ( )
2026-06-29 20:03:50 +00:00
_cfg_cache . clear ( )
# Remember the old mtime so we can tell whether config actually changed
# vs. first-ever load (mtime == 0.0, e.g. server start or profile switch).
_old_cfg_mtime = _cfg_mtime
_cfg_path = config_path
_cfg_mtime = 0.0
try :
if config_path . exists ( ) :
# Route the parse through the mtime-keyed cache (#4652) so an
# unchanged config.yaml isn't re-parsed (~125ms+ on a large file)
# on every reload_config() on the hot path (profile switch /
# load_settings, #4662 Phase 2). We take the RAW cached dict and
# run the env expansion HERE, pinned to the unscoped process-env
# view (below) — never the helper's per-call expansion — for the
# #798 TLS reason documented in the pin block.
loaded = _load_yaml_config_file_raw ( config_path )
if isinstance ( loaded , dict ) :
if loaded :
# The process-global _cfg_cache must reflect PROCESS-env
# expansion, never a profile-scoped block_process_env_fallback
# view — otherwise a reload that fires while a readonly/worker
# scope is active (profile alternation resolves _get_config_path
# to the named profile, #798 TLS) would bake under-expanded
# literal ${VAR}s into the shared cache and starve concurrent
# readers of the module-level `cfg` alias. Expansion re-runs
# per-read elsewhere; here we pin the cache to the unscoped view.
_prev_block = getattr ( _thread_ctx , " block_process_env_fallback " , False )
_prev_env = getattr ( _thread_ctx , " env " , None )
2026-04-16 18:55:18 +00:00
try :
2026-06-29 20:03:50 +00:00
_thread_ctx . block_process_env_fallback = False
_thread_ctx . env = { }
_cfg_cache . update ( _expand_env_vars ( loaded ) )
finally :
_thread_ctx . block_process_env_fallback = _prev_block
if _prev_env is None :
try :
del _thread_ctx . env
except AttributeError :
pass
else :
_thread_ctx . env = _prev_env
# Stamp _cfg_mtime whenever the file parsed to a dict — INCLUDING
# an empty {} config. The cache-update above is skipped for {} (it's
# a no-op), but _cfg_mtime MUST still be set or get_config()'s
# `current_mtime != _cfg_mtime` stale check fires on every call and
# spins reload_config() under _cfg_lock forever (a `{}` config from a
# freshly created/reset profile is reachable on the switch hot path).
# This matches master's pre-#4662 behavior (it entered the block for
# {} and set the mtime); the inner `if loaded:` only gates the no-op
# cache update, not the mtime stamp.
try :
_cfg_mtime = Path ( config_path ) . stat ( ) . st_mtime
except OSError :
_cfg_mtime = 0.0
except Exception :
logger . debug ( " Failed to load yaml config from %s " , config_path )
_apply_config_defaults ( _cfg_cache )
_cfg_fingerprint = _fingerprint_config ( _cfg_cache )
# Bust the models cache so the next request sees fresh config values.
# Only delete the disk cache when config has actually changed -- not on
# first-ever load (when _old_cfg_mtime == 0.0, i.e. server start or
# profile switch) -- preserving the disk cache so the next restart
# still hits the fast path without a cold run.
if _old_cfg_mtime != 0.0 :
_delete_models_cache_on_disk ( )
def reload_config ( ) - > None :
""" Reload config.yaml from the active profile ' s directory. """
with _cfg_lock :
_refresh_config_cache ( _get_config_path ( ) )
2026-03-31 03:40:19 +00:00
2026-04-12 07:11:41 +00:00
2026-06-22 04:45:13 +00:00
# Memoized parse cache for _load_yaml_config_file, keyed on (resolved path,
# st_mtime_ns, st_size). yaml.safe_load on an ~800-line / 24KB config.yaml costs
# ~125ms of pure-Python parsing, and hot read paths (e.g. GET /api/reasoning ->
# get_reasoning_status) call this on every request. Without a cache, a UI sync
# storm turns into a YAML-reparse storm (#4650). We cache the RAW parsed dict and
# re-run _expand_env_vars() on every call: env expansion is cheap, always returns
# a fresh structure (so callers that read-modify-save the result never corrupt the
# cache), and keeps ${VAR} references live against the current os.environ. The
# (mtime_ns, size) key means any on-disk edit (including by _save_yaml_config_file)
# is picked up on the next read.
_yaml_file_cache : dict [ str , tuple ] = { }
_yaml_file_cache_lock = threading . Lock ( )
2026-06-23 05:23:21 +00:00
def _load_yaml_config_file_raw ( config_path : Path , * , _copy : bool = True ) - > dict :
2026-06-23 02:13:33 +00:00
""" Return the RAW (un-env-expanded) parsed config dict, memoized on
( resolved path , st_mtime_ns , st_size ) . Shared parse core for
_load_yaml_config_file ( ) and reload_config ( ) : the former runs the helper ' s
own per - call env expansion on the result ; the latter must run expansion
under its own process - env - pinned thread context ( #798), so it takes the raw
dict and expands it itself . Either way the file is parsed at most once per
( mtime , size ) — a UI sync storm can ' t turn into a YAML-reparse storm (#4650),
and an unchanged config . yaml isn ' t reparsed on the profile-switch hot path
( #4662 Phase 2).
2026-06-23 05:23:21 +00:00
By default returns a deep copy so a caller can never mutate the shared cache
entry ( greptile #4741). Internal callers that immediately pass the result
through _expand_env_vars ( ) ( which itself returns a fresh structure and never
mutates its input ) pass _copy = False to skip the redundant copy on the hot path .
2026-06-23 02:13:33 +00:00
"""
2026-04-20 22:12:01 +00:00
try :
import yaml as _yaml
except ImportError :
return { }
2026-06-22 04:45:13 +00:00
try :
st = config_path . stat ( )
except OSError :
# Missing or unstattable file — preserve the original "no config" contract.
2026-04-20 22:12:01 +00:00
return { }
2026-06-22 04:45:13 +00:00
cache_key = str ( config_path )
stat_key = ( st . st_mtime_ns , st . st_size )
with _yaml_file_cache_lock :
cached = _yaml_file_cache . get ( cache_key )
if cached is not None and cached [ 0 ] == stat_key :
2026-06-23 02:13:33 +00:00
raw = cached [ 1 ]
2026-06-23 05:23:21 +00:00
if not isinstance ( raw , dict ) :
return { }
return copy . deepcopy ( raw ) if _copy else raw
2026-06-22 04:45:13 +00:00
# Cache miss / stale: parse off disk. Done outside the lock so a slow parse
# doesn't serialize unrelated paths; a concurrent duplicate parse is harmless.
2026-04-20 22:12:01 +00:00
try :
loaded = _yaml . safe_load ( config_path . read_text ( encoding = " utf-8 " ) )
except Exception :
logger . debug ( " Failed to parse yaml config from %s " , config_path )
return { }
2026-06-22 04:45:13 +00:00
raw = loaded if isinstance ( loaded , dict ) else { }
with _yaml_file_cache_lock :
_yaml_file_cache [ cache_key ] = ( stat_key , raw )
2026-06-23 05:23:21 +00:00
return copy . deepcopy ( raw ) if _copy else raw
2026-06-23 02:13:33 +00:00
def _load_yaml_config_file ( config_path : Path ) - > dict :
2026-06-23 05:23:21 +00:00
# _copy=False: _expand_env_vars returns a fresh structure and never mutates
# its input, so the env-expanded result is already cache-safe — no need to
# deep-copy the raw dict first (keeps the /api/reasoning hot path cheap).
raw = _load_yaml_config_file_raw ( config_path , _copy = False )
2026-06-22 04:45:13 +00:00
if not raw :
return { }
expanded = _expand_env_vars ( raw )
return expanded if isinstance ( expanded , dict ) else { }
2026-04-20 22:12:01 +00:00
2026-06-03 01:33:22 +00:00
def get_config_for_profile_home ( profile_home : " Path | str | None " ) - > dict :
""" Return the config dict for an explicit profile home directory.
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 through ` ` get_active_profile_name ( ) ` ` which falls
back to the process - global ` ` _active_profile ` ` ( usually ` ` default ` ` ) — so a
session running under a non - default profile would silently read the
* * default * * profile ' s ``config.yaml`` for toolsets, prefill context, and
fallback chains ( issue #3294).
This helper reads the config for a * known * profile home directly off disk ,
bypassing the thread - local resolver entirely . When ` ` profile_home ` ` matches
the path the ambient resolver would pick ( the common single - profile case ) ,
we return the cached ` ` get_config ( ) ` ` to preserve in - memory overrides used
by tests and runtime callers . Only when the session ' s profile home diverges
from the ambient path do we read the session profile ' s file directly — a
pure read with no global cache mutation , so it is race - free across
concurrent sessions on different profiles .
"""
if not profile_home :
return get_config ( )
try :
target = Path ( profile_home ) . expanduser ( )
except Exception :
return get_config ( )
# If the ambient resolver already points at this profile home, defer to
2026-06-20 03:57:56 +00:00
# get_config() so in-memory overrides (monkeypatched cfg) are honored. This
# MUST run before the nonexistent-home guard below: a matching ambient home
# whose directory doesn't physically exist yet (fresh install, monkeypatched
# cfg) must still resolve through get_config(), not return {} (#4516 gate).
2026-06-03 01:33:22 +00:00
try :
if _get_config_path ( ) . parent == target :
return get_config ( )
except Exception :
pass
2026-06-20 03:57:56 +00:00
if not target . exists ( ) :
return { }
2026-06-20 03:51:15 +00:00
# Read the profile file directly and apply documented defaults locally so the
# returned dict matches ambient get_config() shape (including built-in
# personalities) without mutating any global cache state.
profile_cfg = _load_yaml_config_file ( target / " config.yaml " )
_apply_config_defaults ( profile_cfg )
return profile_cfg
2026-06-03 01:33:22 +00:00
2026-06-19 22:26:30 +00:00
def _config_for_yaml_save ( config_data : dict ) - > dict :
""" Return a YAML-safe config copy without runtime-only expanded defaults. """
if not isinstance ( config_data , dict ) :
return { }
data = copy . deepcopy ( config_data )
agent_cfg = data . get ( " agent " )
if isinstance ( agent_cfg , dict ) :
personalities = agent_cfg . get ( " personalities " )
if isinstance ( personalities , dict ) :
custom_personalities = {
name : value
for name , value in personalities . items ( )
if _DEFAULT_AGENT_PERSONALITIES . get ( name ) != value
}
if custom_personalities :
agent_cfg [ " personalities " ] = custom_personalities
else :
agent_cfg . pop ( " personalities " , None )
if not agent_cfg :
data . pop ( " agent " , None )
return data
2026-04-20 22:12:01 +00:00
def _save_yaml_config_file ( config_path : Path , config_data : dict ) - > None :
try :
import yaml as _yaml
except ImportError as exc :
raise RuntimeError ( " PyYAML is required to write Hermes config.yaml " ) from exc
config_path . parent . mkdir ( parents = True , exist_ok = True )
config_path . write_text (
2026-06-19 22:26:30 +00:00
_yaml . safe_dump ( _config_for_yaml_save ( config_data ) , sort_keys = False , allow_unicode = True ) ,
2026-04-20 22:12:01 +00:00
encoding = " utf-8 " ,
)
2026-06-22 04:45:13 +00:00
# Invalidate the memoized parse for this path so the next read re-parses the
# bytes we just wrote. mtime_ns+size keying normally catches edits, but a
# WebUI save that preserves size with a coarse/unchanged mtime could otherwise
# serve a stale dict (#4650 review) — evicting on our own write closes that gap.
with _yaml_file_cache_lock :
_yaml_file_cache . pop ( str ( config_path ) , None )
2026-04-20 22:12:01 +00:00
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
# Initial load
reload_config ( )
cfg = _cfg_cache # alias for backward compat with existing references
2026-03-31 03:40:19 +00:00
2026-04-12 07:11:41 +00:00
2026-03-31 03:40:19 +00:00
# ── Default workspace discovery ───────────────────────────────────────────────
2026-04-13 21:28:24 +00:00
def _workspace_candidates ( raw : str | Path | None = None ) - > list [ Path ] :
""" Return ordered candidate workspace paths, de-duplicated. """
candidates : list [ Path ] = [ ]
def add ( candidate : str | Path | None ) - > None :
if candidate in ( None , " " ) :
return
try :
path = Path ( candidate ) . expanduser ( ) . resolve ( )
except Exception :
return
if path not in candidates :
candidates . append ( path )
add ( raw )
if os . getenv ( " HERMES_WEBUI_DEFAULT_WORKSPACE " ) :
add ( os . getenv ( " HERMES_WEBUI_DEFAULT_WORKSPACE " ) )
home_workspace = HOME / " workspace "
home_work = HOME / " work "
if home_workspace . exists ( ) :
add ( home_workspace )
if home_work . exists ( ) :
add ( home_work )
add ( home_workspace )
add ( STATE_DIR / " workspace " )
return candidates
def _ensure_workspace_dir ( path : Path ) - > bool :
""" Best-effort check that a workspace directory exists and is writable. """
try :
path = path . expanduser ( ) . resolve ( )
path . mkdir ( parents = True , exist_ok = True )
return path . is_dir ( ) and os . access ( path , os . R_OK | os . W_OK | os . X_OK )
except Exception :
return False
def resolve_default_workspace ( raw : str | Path | None = None ) - > Path :
""" Return the first usable workspace path, creating it when possible. """
for candidate in _workspace_candidates ( raw ) :
if _ensure_workspace_dir ( candidate ) :
return candidate
raise RuntimeError (
" Could not create or access any usable workspace directory. "
" Set HERMES_WEBUI_DEFAULT_WORKSPACE to a writable path. "
)
2026-03-31 03:40:19 +00:00
def _discover_default_workspace ( ) - > Path :
"""
Resolve the default workspace in order :
1. HERMES_WEBUI_DEFAULT_WORKSPACE env var
2026-04-13 21:28:24 +00:00
2. ~ / workspace if it already exists
3. ~ / work if it already exists
4. ~ / workspace ( create if needed )
5. STATE_DIR / workspace
2026-03-31 03:40:19 +00:00
"""
2026-04-13 21:28:24 +00:00
return resolve_default_workspace ( )
2026-04-12 07:11:41 +00:00
2026-03-31 03:40:19 +00:00
DEFAULT_WORKSPACE = _discover_default_workspace ( )
2026-04-18 06:46:43 +00:00
DEFAULT_MODEL = os . getenv ( " HERMES_WEBUI_DEFAULT_MODEL " , " " ) # Empty = use provider default; avoids showing unavailable OpenAI model to non-OpenAI users (#646)
2026-04-12 07:11:41 +00:00
2026-03-31 03:40:19 +00:00
# ── Startup diagnostics ───────────────────────────────────────────────────────
2026-06-10 19:41:47 +00:00
def _warn_state_dir_divergence ( warn_prefix : str ) - > None :
""" Check if SESSION_DIR is empty but a sibling state directory has session data.
If the session store looks empty ( no * . json files besides _index . json in SESSION_DIR ,
or SESSION_INDEX_FILE is absent / empty / contains only { } | [ ] | null ) , scan STATE_DIR . parent
for sibling directories with a sessions / child that has . json files .
Prints a diagnostic warning if a divergence is detected , helping users identify when
they may have switched launch methods and the HERMES_WEBUI_STATE_DIR env var differs .
"""
try :
# Check if session store is empty
session_dir_empty = False
# Check for .json files in SESSION_DIR (excluding _index.json)
if SESSION_DIR . exists ( ) :
json_files = [ f for f in SESSION_DIR . glob ( " *.json " ) if f . name != " _index.json " ]
session_dir_empty = len ( json_files ) == 0
else :
session_dir_empty = True
# Check if SESSION_INDEX_FILE is absent, empty, or contains only {}|[]|null
index_file_empty = True
if SESSION_INDEX_FILE . exists ( ) :
try :
with open ( SESSION_INDEX_FILE , " r " ) as f :
content = f . read ( ) . strip ( )
if content and content not in ( " {} " , " [] " , " null " ) :
index_file_empty = False
except Exception :
pass
# If session store looks empty, scan for siblings with sessions
if session_dir_empty and index_file_empty :
state_parent = STATE_DIR . parent
if state_parent . exists ( ) :
for sibling in state_parent . iterdir ( ) :
if not sibling . is_dir ( ) or sibling == STATE_DIR :
continue
sibling_sessions = sibling / " sessions "
if sibling_sessions . exists ( ) :
json_files = [ f for f in sibling_sessions . glob ( " *.json " ) if f . name != " _index.json " ]
if json_files :
# Found a sibling with session data
print (
f " { warn_prefix } STATE_DIR is empty but a sibling state directory has session data. \n "
f " Current : { STATE_DIR } \n "
f " Sibling : { sibling } \n "
f " If you switched launch methods (bootstrap.py / ctl.sh / systemd), \n "
f " the active HERMES_WEBUI_STATE_DIR env var may differ from the \n "
f " previous run. Set it explicitly to restore access: \n "
f " export HERMES_WEBUI_STATE_DIR= { sibling } " ,
flush = True ,
)
return
except Exception :
pass
2026-04-05 06:30:20 +00:00
def print_startup_config ( ) - > None :
2026-03-31 03:40:19 +00:00
""" Print detected configuration at startup so the user can verify what was found. """
2026-04-12 07:11:41 +00:00
ok = " \033 [32m[ok] \033 [0m "
warn = " \033 [33m[!!] \033 [0m "
err = " \033 [31m[XX] \033 [0m "
2026-03-31 03:40:19 +00:00
lines = [
2026-04-12 07:11:41 +00:00
" " ,
" Hermes Web UI -- startup config " ,
" -------------------------------- " ,
f " repo root : { REPO_ROOT } " ,
f " agent dir : { _AGENT_DIR if _AGENT_DIR else ' NOT FOUND ' } { ok if _AGENT_DIR else err } " ,
f " python : { PYTHON_EXE } " ,
f " state dir : { STATE_DIR } " ,
f " workspace : { DEFAULT_WORKSPACE } " ,
f " host:port : { HOST } : { PORT } " ,
f " config file : { _get_config_path ( ) } { ' (found) ' if _get_config_path ( ) . exists ( ) else ' (not found, using defaults) ' } " ,
" " ,
2026-03-31 03:40:19 +00:00
]
2026-04-12 07:11:41 +00:00
print ( " \n " . join ( lines ) , flush = True )
2026-03-31 03:40:19 +00:00
2026-06-10 19:41:47 +00:00
try :
_warn_state_dir_divergence ( warn )
except Exception :
pass
2026-03-31 03:40:19 +00:00
if not _HERMES_FOUND :
print (
2026-04-12 07:11:41 +00:00
f " { err } Could not find the Hermes agent directory. \n "
" The server will start but agent features will not work. \n "
" \n "
" To fix, set one of: \n "
" export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent \n "
" export HERMES_HOME=/path/to/.hermes \n "
" \n "
" Or clone hermes-agent as a sibling of this repo: \n "
" git clone <hermes-agent-repo> ../hermes-agent \n " ,
flush = True ,
2026-03-31 03:40:19 +00:00
)
2026-04-12 07:11:41 +00:00
2026-04-05 06:30:20 +00:00
def verify_hermes_imports ( ) - > tuple :
2026-03-31 03:40:19 +00:00
"""
Attempt to import the key Hermes modules .
2026-04-04 17:59:33 +00:00
Returns ( ok : bool , missing : list [ str ] , errors : dict [ str , str ] ) .
2026-03-31 03:40:19 +00:00
"""
2026-04-12 07:11:41 +00:00
required = [ " run_agent " ]
missing = [ ]
errors = { }
2026-03-31 03:40:19 +00:00
for mod in required :
try :
__import__ ( mod )
2026-04-04 17:59:33 +00:00
except Exception as e :
2026-03-31 03:40:19 +00:00
missing . append ( mod )
2026-04-04 17:59:33 +00:00
# Capture the full error message so startup logs show WHY
# (e.g. pydantic_core .so mismatch) instead of just the name.
errors [ mod ] = f " { type ( e ) . __name__ } : { e } "
return ( len ( missing ) == 0 ) , missing , errors
2026-03-31 03:40:19 +00:00
2026-04-12 07:11:41 +00:00
2026-03-31 03:40:19 +00:00
# ── Limits ───────────────────────────────────────────────────────────────────
2026-06-03 03:58:22 +00:00
MAX_FILE_BYTES = 400_000
2026-05-15 02:20:05 +00:00
MAX_UPLOAD_BYTES = _env_mb_bytes ( " HERMES_WEBUI_MAX_UPLOAD_MB " , 20 )
2026-03-31 03:40:19 +00:00
# ── File type maps ───────────────────────────────────────────────────────────
2026-04-12 07:11:41 +00:00
IMAGE_EXTS = { " .png " , " .jpg " , " .jpeg " , " .gif " , " .svg " , " .webp " , " .ico " , " .bmp " }
MD_EXTS = { " .md " , " .markdown " , " .mdown " }
CODE_EXTS = {
" .py " ,
" .js " ,
" .ts " ,
" .jsx " ,
" .tsx " ,
" .css " ,
" .html " ,
" .json " ,
" .yaml " ,
" .yml " ,
" .toml " ,
" .sh " ,
" .bash " ,
" .txt " ,
" .log " ,
" .env " ,
" .csv " ,
" .xml " ,
" .sql " ,
" .rs " ,
" .go " ,
" .java " ,
" .c " ,
" .cpp " ,
" .h " ,
}
2026-03-31 03:40:19 +00:00
MIME_MAP = {
2026-04-12 07:11:41 +00:00
" .png " : " image/png " ,
" .jpg " : " image/jpeg " ,
" .jpeg " : " image/jpeg " ,
" .gif " : " image/gif " ,
" .svg " : " image/svg+xml " ,
" .webp " : " image/webp " ,
" .ico " : " image/x-icon " ,
" .bmp " : " image/bmp " ,
" .pdf " : " application/pdf " ,
" .json " : " application/json " ,
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
" .html " : " text/html " ,
" .htm " : " text/html " ,
2026-04-16 05:43:31 +00:00
" .xls " : " application/vnd.ms-excel " ,
" .xlsx " : " application/vnd.openxmlformats-officedocument.spreadsheetml.sheet " ,
" .doc " : " application/msword " ,
" .docx " : " application/vnd.openxmlformats-officedocument.wordprocessingml.document " ,
2026-04-30 02:54:07 +00:00
" .mp3 " : " audio/mpeg " ,
" .wav " : " audio/wav " ,
" .m4a " : " audio/mp4 " ,
" .aac " : " audio/aac " ,
" .ogg " : " audio/ogg " ,
" .oga " : " audio/ogg " ,
" .opus " : " audio/opus " ,
" .flac " : " audio/flac " ,
" .mp4 " : " video/mp4 " ,
" .mov " : " video/quicktime " ,
" .m4v " : " video/mp4 " ,
" .webm " : " video/webm " ,
" .ogv " : " video/ogg " ,
2026-03-31 03:40:19 +00:00
}
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
_DEFAULT_TOOLSETS = [
2026-04-12 07:11:41 +00:00
" browser " ,
" clarify " ,
" code_execution " ,
" cronjob " ,
" delegation " ,
" file " ,
" image_gen " ,
" memory " ,
" session_search " ,
" skills " ,
" terminal " ,
" todo " ,
" web " ,
" webhook " ,
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
]
2026-05-15 20:08:22 +00:00
_LEGACY_CLI_TOOLSET_ALIASES = {
# Older Hermes configs used "hermes" as the CLI composite toolset. Modern
# Hermes Agent exposes that split as these two registered composites; keep
# WebUI sessions usable when pointed at an older shared config.yaml.
" hermes " : ( " hermes-cli " , " hermes-api-server " ) ,
}
def _normalize_cli_toolsets ( toolsets ) :
""" Expand legacy CLI toolset aliases while preserving order and de-duping. """
normalized = [ ]
seen = set ( )
for name in toolsets or [ ] :
replacements = _LEGACY_CLI_TOOLSET_ALIASES . get ( name , ( name , ) )
for replacement in replacements :
if replacement and replacement not in seen :
seen . add ( replacement )
normalized . append ( replacement )
return normalized
2026-04-16 06:39:07 +00:00
def _resolve_cli_toolsets ( cfg = None ) :
""" Resolve CLI toolsets using the agent ' s _get_platform_tools() so that
MCP server toolsets are automatically included , matching CLI behaviour . """
if cfg is None :
cfg = get_config ( )
try :
from hermes_cli . tools_config import _get_platform_tools
2026-05-15 20:08:22 +00:00
return _normalize_cli_toolsets ( _get_platform_tools ( cfg , " cli " ) )
2026-04-16 06:39:07 +00:00
except Exception :
# Fallback: read raw list from config (MCP toolsets will be missing)
2026-05-15 20:08:22 +00:00
return _normalize_cli_toolsets ( cfg . get ( " platform_toolsets " , { } ) . get ( " cli " , _DEFAULT_TOOLSETS ) )
2026-04-16 06:39:07 +00:00
CLI_TOOLSETS = _resolve_cli_toolsets ( )
2026-03-31 03:40:19 +00:00
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
# ── Model / provider discovery ───────────────────────────────────────────────
# Hardcoded fallback models (used when no config.yaml or agent is available)
2026-04-15 22:20:25 +00:00
# Also used as the OpenRouter model list — keep this curated to current, widely-used models.
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
_FALLBACK_MODELS = [
2026-04-15 22:27:55 +00:00
# OpenAI
{ " provider " : " OpenAI " , " id " : " openai/gpt-5.4-mini " , " label " : " GPT-5.4 Mini " } ,
{ " provider " : " OpenAI " , " id " : " openai/gpt-5.4 " , " label " : " GPT-5.4 " } ,
# Anthropic — 4.6 flagship + 4.5 generation
2026-04-30 04:06:30 +00:00
{ " provider " : " Anthropic " , " id " : " anthropic/claude-opus-4.7 " , " label " : " Claude Opus 4.7 " } ,
2026-04-15 22:27:55 +00:00
{ " provider " : " Anthropic " , " id " : " anthropic/claude-opus-4.6 " , " label " : " Claude Opus 4.6 " } ,
{ " provider " : " Anthropic " , " id " : " anthropic/claude-sonnet-4.6 " , " label " : " Claude Sonnet 4.6 " } ,
{ " provider " : " Anthropic " , " id " : " anthropic/claude-sonnet-4-5 " , " label " : " Claude Sonnet 4.5 " } ,
{ " provider " : " Anthropic " , " id " : " anthropic/claude-haiku-4-5 " , " label " : " Claude Haiku 4.5 " } ,
2026-04-18 17:09:59 +00:00
# Google — 3.x (latest preview) + 2.5 (stable GA)
{ " provider " : " Google " , " id " : " google/gemini-3.1-pro-preview " , " label " : " Gemini 3.1 Pro Preview " } ,
{ " provider " : " Google " , " id " : " google/gemini-3-flash-preview " , " label " : " Gemini 3 Flash Preview " } ,
{ " provider " : " Google " , " id " : " google/gemini-3.1-flash-lite-preview " , " label " : " Gemini 3.1 Flash Lite Preview " } ,
{ " provider " : " Google " , " id " : " google/gemini-2.5-pro " , " label " : " Gemini 2.5 Pro " } ,
{ " provider " : " Google " , " id " : " google/gemini-2.5-flash " , " label " : " Gemini 2.5 Flash " } ,
2026-04-15 22:27:55 +00:00
# DeepSeek
2026-04-28 03:04:26 +00:00
{ " provider " : " DeepSeek " , " id " : " deepseek/deepseek-v4-flash " , " label " : " DeepSeek V4 Flash " } ,
{ " provider " : " DeepSeek " , " id " : " deepseek/deepseek-v4-pro " , " label " : " DeepSeek V4 Pro " } ,
2026-04-28 06:40:11 +00:00
{ " provider " : " DeepSeek " , " id " : " deepseek/deepseek-chat-v3-0324 " , " label " : " DeepSeek V3 (legacy) " } ,
{ " provider " : " DeepSeek " , " id " : " deepseek/deepseek-r1 " , " label " : " DeepSeek R1 (legacy) " } ,
2026-04-15 22:54:18 +00:00
# Qwen (Alibaba) — strong coding and general models
{ " provider " : " Qwen " , " id " : " qwen/qwen3-coder " , " label " : " Qwen3 Coder " } ,
{ " provider " : " Qwen " , " id " : " qwen/qwen3.6-plus " , " label " : " Qwen3.6 Plus " } ,
# xAI
2026-04-15 23:00:29 +00:00
{ " provider " : " xAI " , " id " : " x-ai/grok-4.20 " , " label " : " Grok 4.20 " } ,
2026-04-15 22:27:55 +00:00
# Mistral
{ " provider " : " Mistral " , " id " : " mistralai/mistral-large-latest " , " label " : " Mistral Large " } ,
2026-04-18 07:18:20 +00:00
# MiniMax
2026-06-03 01:08:01 +00:00
{ " provider " : " MiniMax " , " id " : " minimax/MiniMax-M3 " , " label " : " MiniMax M3 " } ,
2026-04-18 07:18:20 +00:00
{ " provider " : " MiniMax " , " id " : " minimax/MiniMax-M2.7 " , " label " : " MiniMax M2.7 " } ,
{ " provider " : " MiniMax " , " id " : " minimax/MiniMax-M2.7-highspeed " , " label " : " MiniMax M2.7 Highspeed " } ,
2026-04-28 03:23:47 +00:00
# Z.AI / GLM
2026-06-20 21:22:53 +00:00
{ " provider " : " Z.AI " , " id " : " zai/glm-5.2 " , " label " : " GLM-5.2 " } ,
2026-04-28 03:23:47 +00:00
{ " provider " : " Z.AI " , " id " : " zai/glm-5.1 " , " label " : " GLM-5.1 " } ,
{ " provider " : " Z.AI " , " id " : " zai/glm-5 " , " label " : " GLM-5 " } ,
{ " provider " : " Z.AI " , " id " : " zai/glm-5-turbo " , " label " : " GLM-5 Turbo " } ,
{ " provider " : " Z.AI " , " id " : " zai/glm-4.7 " , " label " : " GLM-4.7 " } ,
{ " provider " : " Z.AI " , " id " : " zai/glm-4.5 " , " label " : " GLM-4.5 " } ,
{ " provider " : " Z.AI " , " id " : " zai/glm-4.5-flash " , " label " : " GLM-4.5 Flash " } ,
2026-05-03 18:20:24 +00:00
# OpenRouter free-tier models — must appear in fallback list so they
# are visible even when the tool-support filter in hermes_cli strips
# them out of the live catalog (see #1426).
{ " provider " : " OpenRouter " , " id " : " openrouter/elephant-alpha " , " label " : " Elephant Alpha (free) " } ,
{ " provider " : " OpenRouter " , " id " : " openrouter/owl-alpha " , " label " : " Owl Alpha (free) " } ,
fix: resolve rsplit collision for OpenRouter models with :free/:beta/:thinking suffixes (#1744)
The previous approach of prepending 'openrouter/' to the model ID in the
catalog was incorrect — it only masked the symptom while regressing the
config_provider=openrouter codepath.
The root cause is in resolve_model_provider(): rsplit(':', 1) on
'@openrouter:tencent/hy3-preview:free' yields provider='openrouter:tencent/hy3-preview'
and model='free', because the ':free' suffix collides with the @provider:model
grammar.
Fix: after rsplit, validate that the extracted provider hint is a known
provider (in _PROVIDER_MODELS, _PROVIDER_DISPLAY, or starts with 'custom:').
If not, fall back to split(':', 1) so trailing suffixes stay attached to
the model ID.
This fixes all current and future OR models with colon-suffixed tags
(:free, :beta, :thinking, :nitro, etc.) without catalog changes.
Also adds regression tests for the affected models and edge cases.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-05-06 23:50:00 +00:00
{ " provider " : " OpenRouter " , " id " : " tencent/hy3-preview:free " , " label " : " Hy3 Preview (free) " } ,
2026-05-03 18:20:24 +00:00
{ " provider " : " OpenRouter " , " id " : " nvidia/nemotron-3-super-120b-a12b:free " , " label " : " Nemotron 3 Super (free) " } ,
{ " provider " : " OpenRouter " , " id " : " arcee-ai/trinity-large-preview:free " , " label " : " Trinity Large Preview (free) " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
]
# Provider display names for known Hermes provider IDs
_PROVIDER_DISPLAY = {
2026-04-12 07:11:41 +00:00
" nous " : " Nous Portal " ,
" openrouter " : " OpenRouter " ,
" anthropic " : " Anthropic " ,
" openai " : " OpenAI " ,
2026-06-03 01:08:01 +00:00
" openai-api " : " OpenAI API " ,
2026-04-12 07:11:41 +00:00
" openai-codex " : " OpenAI Codex " ,
2026-05-19 02:49:54 +00:00
" xai-oauth " : " xAI Grok OAuth " ,
2026-04-12 07:11:41 +00:00
" copilot " : " GitHub Copilot " ,
2026-06-30 23:51:10 +00:00
" moa " : " Mixture of Agents " ,
2026-05-23 08:00:39 +00:00
" cursor-acp " : " Cursor ACP " ,
2026-04-12 07:11:41 +00:00
" zai " : " Z.AI / GLM " ,
" kimi-coding " : " Kimi / Moonshot " ,
" deepseek " : " DeepSeek " ,
" minimax " : " MiniMax " ,
2026-04-29 07:50:32 +00:00
" minimax-cn " : " MiniMax (China) " ,
2026-04-12 07:11:41 +00:00
" google " : " Google " ,
" meta-llama " : " Meta Llama " ,
" huggingface " : " HuggingFace " ,
" alibaba " : " Alibaba " ,
" ollama " : " Ollama " ,
2026-04-22 20:18:02 +00:00
" ollama-cloud " : " Ollama Cloud " ,
2026-04-14 01:46:11 +00:00
" opencode-zen " : " OpenCode Zen " ,
" opencode-go " : " OpenCode Go " ,
2026-04-12 07:11:41 +00:00
" lmstudio " : " LM Studio " ,
2026-04-15 22:27:55 +00:00
" mistralai " : " Mistral " ,
2026-04-15 22:54:18 +00:00
" qwen " : " Qwen " ,
" x-ai " : " xAI " ,
2026-04-28 10:17:19 +00:00
" nvidia " : " NVIDIA NIM " ,
2026-05-10 08:48:37 +00:00
" xiaomi " : " Xiaomi " ,
2026-05-23 07:56:23 +00:00
" bedrock " : " AWS Bedrock " ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
}
2026-04-22 00:24:54 +00:00
# Provider alias → canonical slug. Users configure providers using the
# dotted/hyphenated form they see on the provider website (``z.ai``,
# ``x.ai``, ``google``) but the internal catalog (``_PROVIDER_MODELS``)
# uses slugs without punctuation (``zai``, ``xai``, ``gemini``). Without
# normalisation the provider lands in the ``else`` branch of the group
# builder and no models are returned — the bug behind #815.
#
# This table is authoritative for the WebUI. When ``hermes_cli.models``
# is importable we also merge its ``_PROVIDER_ALIASES`` on top so any
# new aliases added to the agent automatically apply. Keeping the local
# copy means the fix works even in environments where the agent tree is
# not on ``sys.path`` (CI, installs without hermes-agent cloned
# alongside the WebUI).
_PROVIDER_ALIASES = {
" glm " : " zai " ,
" z-ai " : " zai " ,
" z.ai " : " zai " ,
" zhipu " : " zai " ,
" github " : " copilot " ,
" github-copilot " : " copilot " ,
" github-models " : " copilot " ,
" github-model " : " copilot " ,
" google " : " gemini " ,
" google-gemini " : " gemini " ,
" google-ai-studio " : " gemini " ,
" kimi " : " kimi-coding " ,
" moonshot " : " kimi-coding " ,
" claude " : " anthropic " ,
" claude-code " : " anthropic " ,
" deep-seek " : " deepseek " ,
2026-04-29 07:50:32 +00:00
" minimax-china " : " minimax-cn " ,
" minimax_cn " : " minimax-cn " ,
2026-04-22 00:24:54 +00:00
" opencode " : " opencode-zen " ,
" grok " : " xai " ,
" x-ai " : " xai " ,
" x.ai " : " xai " ,
" aws " : " bedrock " ,
" aws-bedrock " : " bedrock " ,
" amazon " : " bedrock " ,
" amazon-bedrock " : " bedrock " ,
" qwen " : " alibaba " ,
" aliyun " : " alibaba " ,
" dashscope " : " alibaba " ,
" alibaba-cloud " : " alibaba " ,
2026-04-28 10:17:19 +00:00
" nim " : " nvidia " ,
" nvidia-nim " : " nvidia " ,
" build-nvidia " : " nvidia " ,
" nemotron " : " nvidia " ,
2026-05-10 08:48:37 +00:00
" mimo " : " xiaomi " ,
" xiaomi-mimo " : " xiaomi " ,
2026-05-01 05:29:42 +00:00
# Legacy alias — earlier WebUI builds wrote ``provider: local`` for unknown
# loopback endpoints, but ``local`` is not registered in
# ``hermes_cli.auth.PROVIDER_REGISTRY``. Routing it through ``custom``
# lets the agent's auxiliary client take the ``no-key-required``
# OpenAI-compat path. See #1384.
" local " : " custom " ,
2026-04-22 00:24:54 +00:00
}
2026-06-26 14:19:58 +00:00
def _get_anthropic_fallback_env_vars ( ) - > tuple [ str , . . . ] :
""" Read Anthropic auth env vars from the shared agent registry when available. """
fallback = (
" ANTHROPIC_API_KEY " ,
" ANTHROPIC_TOKEN " ,
" CLAUDE_CODE_OAUTH_TOKEN " ,
)
try :
from hermes_cli . auth import PROVIDER_REGISTRY
anthropic = (
PROVIDER_REGISTRY . get ( " anthropic " )
if isinstance ( PROVIDER_REGISTRY , dict )
else None
)
env_vars = getattr ( anthropic , " api_key_env_vars " , None )
if not env_vars :
return fallback
out = [ ]
for _var in env_vars :
if not isinstance ( _var , str ) :
continue
_normalized = _var . strip ( )
if _normalized and _normalized not in out :
out . append ( _normalized )
return tuple ( out ) if out else fallback
except Exception :
return fallback
2026-04-22 00:24:54 +00:00
def _resolve_provider_alias ( name : str ) - > str :
""" Return the canonical provider slug for *name*.
Applies the WebUI ' s local alias table first, then merges any
additional aliases the agent provides ( when hermes_cli is on
sys . path ) . Lookup is case - insensitive and whitespace - trimmed .
Unknown names pass through unchanged .
"""
if not name :
return name
raw = str ( name ) . strip ( ) . lower ( )
# Prefer the agent's table when available so new aliases added there
# work automatically; otherwise fall through to our local copy.
try :
from hermes_cli . models import _PROVIDER_ALIASES as _agent_aliases
if raw in _agent_aliases :
return _agent_aliases [ raw ]
except Exception :
pass
return _PROVIDER_ALIASES . get ( raw , name )
2026-06-16 21:45:43 +00:00
def _is_known_model_provider ( provider_id : str ) - > bool :
""" True when *provider_id* names a model provider WebUI can render.
The credential pool ( ` ` auth . json ` ` → ` ` credential_pool ` ` ) stores keys for
BOTH model providers ( whose API keys belong in the model picker ) and
non - model platform plugins . The Photon iMessage plugin , for example ,
writes ` ` photon ` ` / ` ` photon_project ` ` / ` ` photon_user ` ` pool entries that
are messaging - platform credentials , not LLM API keys . Only the former
should surface as provider groups .
Without this gate , #4247's pool-detection loop added *every* pool key to
` ` detected_providers ` ` ; unknown ids then fell through to the global
auto - detected catalog and each phantom provider was painted with the full
model list ( #4324). ``provider_id`` is expected to be the canonical slug
( post ` ` _resolve_provider_alias ` ` ) ; the lookup is case - insensitive .
A provider is " known " when it is a configured custom - provider slug
( ` ` custom : * ` ` ) , appears in WebUI ' s static ``_PROVIDER_DISPLAY`` /
` ` _PROVIDER_MODELS ` ` tables , or is a registered model - provider plugin .
"""
pid = ( provider_id or " " ) . strip ( ) . lower ( )
if not pid :
return False
if pid . startswith ( " custom: " ) :
return True
if pid in _PROVIDER_DISPLAY or pid in _PROVIDER_MODELS :
return True
try :
if _is_plugin_model_provider ( pid ) :
return True
except Exception :
2026-06-16 22:09:57 +00:00
# A transient failure here (import/IO hiccup in the plugin registry) makes
# a real plugin-backed provider briefly look unknown and drop from the
# picker until the next successful check. Surface at warning so it's
# visible in default production logs rather than silently swallowed.
logger . warning ( " plugin model-provider check failed for %s " , pid , exc_info = True )
2026-06-16 21:45:43 +00:00
return False
2026-05-07 13:40:23 +00:00
def _custom_provider_slug_from_name ( name : object ) - > str :
raw = str ( name or " " ) . strip ( ) . lower ( )
if not raw :
return " "
if raw . startswith ( " custom: " ) :
return raw
2026-05-11 05:48:46 +00:00
# Keep name-derived custom provider slugs out of the @provider:model colon
# grammar. Endpoint-derived slugs may still be custom:<host>:<port>, but a
# friendly name like "Local (127.0.0.1:15721)" should not preserve ':'.
slug = re . sub ( r " [^a-z0-9._-]+ " , " - " , raw ) . strip ( " - " )
slug = re . sub ( r " - { 2,} " , " - " , slug )
if not slug :
return " "
return " custom: " + slug
2026-05-07 13:40:23 +00:00
def _custom_provider_entries ( config_obj : dict | None = None ) - > list [ dict ] :
source = config_obj if isinstance ( config_obj , dict ) else cfg
entries = source . get ( " custom_providers " , [ ] )
if not isinstance ( entries , list ) :
return [ ]
return [ entry for entry in entries if isinstance ( entry , dict ) ]
def _named_custom_provider_slugs ( config_obj : dict | None = None ) - > set [ str ] :
return {
slug
for slug in (
_custom_provider_slug_from_name ( entry . get ( " name " ) )
for entry in _custom_provider_entries ( config_obj )
)
if slug
}
def _named_custom_provider_slug_for_provider (
provider : object ,
config_obj : dict | None = None ,
) - > str :
raw = str ( provider or " " ) . strip ( ) . lower ( )
if not raw :
return " "
raw_suffix = raw . removeprefix ( " custom: " )
for entry in _custom_provider_entries ( config_obj ) :
entry_name = str ( entry . get ( " name " ) or " " ) . strip ( ) . lower ( )
slug = _custom_provider_slug_from_name ( entry_name )
if not entry_name or not slug :
continue
if raw in { entry_name , slug } or raw_suffix == slug . removeprefix ( " custom: " ) :
return slug
return " "
def _resolve_configured_provider_id (
provider : object ,
config_obj : dict | None = None ,
* ,
base_url : object = None ,
2026-05-07 17:07:48 +00:00
resolve_alias : bool = True ,
2026-05-07 13:40:23 +00:00
) - > str :
2026-05-07 17:07:48 +00:00
""" Normalize a configured provider id.
When ` ` resolve_alias ` ` is True ( default , used for active - provider /
badge surfaces ) , falls through to ` ` _resolve_provider_alias ` ` after the
named - custom check . When False ( used by ` ` resolve_model_provider ` ` ) ,
preserves the raw provider value so downstream local - server detection
( ` _LOCAL_SERVER_PROVIDERS ` membership in #1625) sees the original name
like ` ` ollama ` ` / ` ` lm - studio ` ` rather than alias - collapsed ` ` custom ` ` /
` ` lmstudio ` ` . The base - url - to - named - slug fallback still runs in both
modes when applicable .
See in - stage absorption note on stage - 313 for the #1625 regression that
motivated the ` ` resolve_alias ` ` flag .
"""
2026-05-07 13:40:23 +00:00
named_slug = _named_custom_provider_slug_for_provider ( provider , config_obj )
if named_slug :
return named_slug
2026-05-07 17:07:48 +00:00
if not resolve_alias :
raw = str ( provider or " " ) . strip ( ) . lower ( )
if base_url and raw == " custom " :
by_base_url = _named_custom_provider_slug_for_base_url ( base_url , config_obj )
if by_base_url :
return by_base_url
return str ( provider or " " )
2026-05-07 13:40:23 +00:00
resolved = _resolve_provider_alias ( provider )
if (
base_url
and str ( resolved or " " ) . strip ( ) . lower ( ) == " custom "
) :
by_base_url = _named_custom_provider_slug_for_base_url ( base_url , config_obj )
if by_base_url :
return by_base_url
return resolved
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
def _canonicalise_provider_id ( name : object ) - > str :
""" Normalise a provider id slug into a stable lowercase-hyphenated form.
Folds underscores to hyphens and lowercases the result , so a user with
` ` providers . opencode_go . api_key ` ` in ` ` config . yaml ` ` and
` ` model . provider : opencode - go ` ` sees ONE provider group , not two
( #1568). Then attempts alias resolution but only if the alias target
is itself a known canonical id in ` ` _PROVIDER_DISPLAY ` ` — this avoids
converting ` ` x - ai ` ` ( canonical in WebUI ' s data structures) to ``xai``
( the hermes_cli alias target which the WebUI doesn ' t index by).
Examples : :
opencode - go - > opencode - go ( canonical , no change )
opencode_go - > opencode - go ( underscore folded )
OpenCode - Go - > opencode - go ( case folded )
OPENCODE_GO - > opencode - go ( both folded )
z_ai - > zai ( alias - resolved — zai is canonical )
x - ai - > x - ai ( preserved — x - ai is canonical )
Empty input passes through as the empty string . Unknown ids preserve
their normalised form .
"""
if not name :
return " "
raw = str ( name ) . strip ( ) . lower ( ) . replace ( " _ " , " - " )
if not raw :
return " "
# Already a canonical id known to _PROVIDER_DISPLAY/_PROVIDER_MODELS:
# keep as-is to avoid round-tripping through aliases (e.g. x-ai → xai).
if raw in _PROVIDER_DISPLAY or raw in _PROVIDER_MODELS :
return raw
2026-07-04 07:23:45 +00:00
# Try alias resolution. Accept the result if it's a canonical id known to
# either _PROVIDER_DISPLAY OR _PROVIDER_MODELS (mirroring the direct-hit
# check above) — some canonical targets (e.g. `gemini`) are indexed in
# _PROVIDER_MODELS but not _PROVIDER_DISPLAY, so a _DISPLAY-only check
# rejected valid aliases like `google-gemini`→`gemini`, leaving the id
# uncanonicalised and silently breaking provider-ownership checks (#5511).
# This still blocks aliases that point at non-canonical/legacy strings.
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
resolved = _resolve_provider_alias ( raw )
2026-07-04 07:23:45 +00:00
if resolved and ( resolved . lower ( ) in _PROVIDER_DISPLAY or resolved . lower ( ) in _PROVIDER_MODELS ) :
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
return resolved . lower ( )
return raw
2026-05-07 13:40:23 +00:00
def _normalize_base_url_for_match ( value : object ) - > str :
url = str ( value or " " ) . strip ( ) . rstrip ( " / " )
if not url :
return " "
parsed_url = urlparse ( url if " :// " in url else f " http:// { url } " )
scheme = ( parsed_url . scheme or " http " ) . lower ( )
netloc = ( parsed_url . netloc or parsed_url . path ) . lower ( ) . rstrip ( " / " )
path = parsed_url . path . rstrip ( " / " )
if not parsed_url . netloc :
path = " "
return f " { scheme } :// { netloc } { path } "
2026-05-15 19:16:23 +00:00
def _custom_endpoint_slugs_for_base_url ( value : object ) - > set [ str ] :
""" Return custom provider slugs that WebUI may derive from a base URL.
Model picker values for endpoint - discovered models have historically used
both ` ` custom : < host > : < port > ` ` and ` ` custom : < host > - < port > ` ` forms . When the
active config already names a local - server provider such as Ollama for that
same base URL , those endpoint slugs are just UI routing hints and should
resolve back to the configured provider rather than requiring a CUSTOM_ * API
key .
"""
url = str ( value or " " ) . strip ( ) . rstrip ( " / " )
if not url :
return set ( )
parsed_url = urlparse ( url if " :// " in url else f " http:// { url } " )
host = ( parsed_url . hostname or " " ) . strip ( ) . lower ( )
if not host :
return set ( )
port = parsed_url . port
if port is None :
scheme = ( parsed_url . scheme or " http " ) . lower ( )
port = 443 if scheme == " https " else 80
return { f " custom: { host } : { port } " , f " custom: { host } - { port } " }
2026-05-18 22:17:45 +00:00
_LEGACY_CUSTOM_API_KEY_ENV_WARNED : set [ str ] = set ( )
def _api_key_env_name ( provider_id : object ) - > str :
""" Return the POSIX-safe default API-key env var for a custom provider id. """
sanitized = re . sub ( r " [^A-Za-z0-9] " , " _ " , str ( provider_id or " " ) ) . upper ( ) . strip ( " _ " )
if not sanitized :
sanitized = " CUSTOM "
if not sanitized . startswith ( " CUSTOM_ " ) :
sanitized = f " CUSTOM_ { sanitized } "
return f " { sanitized } _API_KEY "
def _legacy_custom_api_key_env_name ( provider_id : object ) - > str :
""" Return the pre-#2541 custom-provider env hint shape, if any. """
raw = str ( provider_id or " " ) . strip ( ) . upper ( )
if not raw :
return " "
return f " { raw } _API_KEY "
def _lookup_custom_api_key_env ( provider_id : object ) - > str | None :
""" Look up sanitized custom-provider env first, then legacy broken shape. """
env_name = _api_key_env_name ( provider_id )
2026-06-20 06:24:07 +00:00
api_key = _thread_local_env_value ( env_name ) . strip ( )
2026-05-18 22:17:45 +00:00
if api_key :
return api_key
legacy_env_name = _legacy_custom_api_key_env_name ( provider_id )
if legacy_env_name and legacy_env_name != env_name :
2026-06-20 06:24:07 +00:00
legacy_key = _thread_local_env_value ( legacy_env_name ) . strip ( )
2026-05-18 22:17:45 +00:00
if legacy_key :
if legacy_env_name not in _LEGACY_CUSTOM_API_KEY_ENV_WARNED :
_LEGACY_CUSTOM_API_KEY_ENV_WARNED . add ( legacy_env_name )
logger . warning (
" Custom provider API key env var %s is deprecated; use %s instead " ,
legacy_env_name ,
env_name ,
)
return legacy_key
return None
2026-05-07 13:40:23 +00:00
def _named_custom_provider_slug_for_base_url (
base_url : object ,
config_obj : dict | None = None ,
) - > str :
target = _normalize_base_url_for_match ( base_url )
if not target :
return " "
for entry in _custom_provider_entries ( config_obj ) :
entry_base_url = _normalize_base_url_for_match ( entry . get ( " base_url " ) )
if entry_base_url != target :
continue
return _custom_provider_slug_from_name ( entry . get ( " name " ) ) or " custom "
return " "
2026-06-10 22:16:25 +00:00
def _provider_is_known_or_configured (
provider_id : object ,
config_obj : dict | None = None ,
) - > bool :
""" True when ``provider_id`` is a provider Hermes recognizes (static registry)
or the user has configured ( named custom provider ) , decided from the STATIC
registry + config state only — never from a live / cold catalog snapshot .
This distinguishes a provider Hermes knows how to route ( e . g . ` ` ollama - cloud ` ` ,
whose model group simply isn ' t folded into the current cached catalog yet, or a
named ` ` custom_providers ` ` entry ) from a * genuinely unknown * one
( ` ` @removed : . . . ` ` that is in no registry and configured nowhere ) . The former ' s
explicitly - qualified selection is preserved across a cold catalog ; the latter
falls back to the default so chat / start doesn ' t route to an unrecognized
provider .
DELIBERATE SCOPE ( see the @provider : model guard in
` ` _resolve_compatible_session_model_state ` ` ) : registry membership counts as
" known " even when the user has no key configured for that built - in . We do NOT
require authenticated - credential evidence here , on purpose . The only fully
reliable " is this provider authenticated " signal is the live auth store /
catalog rebuild — exactly the cost the caller ' s ``prefer_cached_catalog`` hot
path avoids — and a cheap env / config - only credential check would mis - classify
providers authenticated via OAuth / auth - store ( ` ` ollama - cloud ` ` among them ) ,
re - introducing the original silent - revert bug for them . A known - but - unconfigured
pick is therefore kept and surfaces a clear run - time auth error rather than a
silent swap to the default .
Deliberately does NOT consult ` ` get_available_models ( ) ` ` / the catalog groups ,
which are exactly what is cold here — re - deriving them live would defeat the
` ` prefer_cached_catalog ` ` hot - path win this guards .
"""
raw = str ( provider_id or " " ) . strip ( ) . lower ( )
if not raw :
return False
# Configured custom provider: a named slug in custom_providers, or any
# ``custom`` / ``custom:<slug>`` form when custom_providers are defined.
if _named_custom_provider_slug_for_provider ( raw , config_obj ) :
return True
if raw == " custom " or raw . startswith ( " custom: " ) :
return bool ( _custom_provider_entries ( config_obj ) )
# Known first-party / built-in provider id (alias-resolved). Static registry
# knowledge that is always available, so a live-discovery provider whose
# catalog group is momentarily absent still counts as known.
canonical = _resolve_provider_alias ( raw )
return (
raw in _PROVIDER_DISPLAY
or canonical in _PROVIDER_DISPLAY
or raw in _PROVIDER_MODELS
or canonical in _PROVIDER_MODELS
)
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
# Well-known models per provider (used to populate dropdown for direct API providers)
_PROVIDER_MODELS = {
2026-04-12 07:11:41 +00:00
" anthropic " : [
2026-04-30 04:06:30 +00:00
{ " id " : " claude-opus-4.7 " , " label " : " Claude Opus 4.7 " } ,
2026-04-12 07:11:41 +00:00
{ " id " : " claude-opus-4.6 " , " label " : " Claude Opus 4.6 " } ,
{ " id " : " claude-sonnet-4.6 " , " label " : " Claude Sonnet 4.6 " } ,
{ " id " : " claude-sonnet-4-5 " , " label " : " Claude Sonnet 4.5 " } ,
{ " id " : " claude-haiku-4-5 " , " label " : " Claude Haiku 4.5 " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-04-12 07:11:41 +00:00
" openai " : [
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
{ " id " : " gpt-5.5 " , " label " : " GPT-5.5 " } ,
{ " id " : " gpt-5.5-mini " , " label " : " GPT-5.5 Mini " } ,
2026-04-12 07:11:41 +00:00
{ " id " : " gpt-5.4-mini " , " label " : " GPT-5.4 Mini " } ,
2026-04-15 22:20:25 +00:00
{ " id " : " gpt-5.4 " , " label " : " GPT-5.4 " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-06-03 01:08:01 +00:00
" openai-api " : [
{ " id " : " gpt-5.5 " , " label " : " GPT-5.5 " } ,
{ " id " : " gpt-5.5-mini " , " label " : " GPT-5.5 Mini " } ,
{ " id " : " gpt-5.4-mini " , " label " : " GPT-5.4 Mini " } ,
{ " id " : " gpt-5.4 " , " label " : " GPT-5.4 " } ,
] ,
2026-04-12 07:11:41 +00:00
" openai-codex " : [
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
{ " id " : " gpt-5.5 " , " label " : " GPT-5.5 " } ,
{ " id " : " gpt-5.5-mini " , " label " : " GPT-5.5 Mini " } ,
2026-04-14 05:35:27 +00:00
{ " id " : " gpt-5.4 " , " label " : " GPT-5.4 " } ,
{ " id " : " gpt-5.4-mini " , " label " : " GPT-5.4 Mini " } ,
{ " id " : " gpt-5.3-codex " , " label " : " GPT-5.3 Codex " } ,
{ " id " : " gpt-5.2-codex " , " label " : " GPT-5.2 Codex " } ,
{ " id " : " gpt-5.1-codex-max " , " label " : " GPT-5.1 Codex Max " } ,
{ " id " : " gpt-5.1-codex-mini " , " label " : " GPT-5.1 Codex Mini " } ,
{ " id " : " codex-mini-latest " , " label " : " Codex Mini (latest) " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-04-12 07:11:41 +00:00
" google " : [
2026-04-18 17:09:59 +00:00
{ " id " : " gemini-3.1-pro-preview " , " label " : " Gemini 3.1 Pro Preview " } ,
{ " id " : " gemini-3-flash-preview " , " label " : " Gemini 3 Flash Preview " } ,
{ " id " : " gemini-3.1-flash-lite-preview " , " label " : " Gemini 3.1 Flash Lite Preview " } ,
{ " id " : " gemini-2.5-pro " , " label " : " Gemini 2.5 Pro " } ,
{ " id " : " gemini-2.5-flash " , " label " : " Gemini 2.5 Flash " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-04-12 07:11:41 +00:00
" deepseek " : [
2026-04-28 03:04:26 +00:00
{ " id " : " deepseek-v4-flash " , " label " : " DeepSeek V4 Flash " } ,
{ " id " : " deepseek-v4-pro " , " label " : " DeepSeek V4 Pro " } ,
2026-04-28 06:40:11 +00:00
{ " id " : " deepseek-chat-v3-0324 " , " label " : " DeepSeek V3 (legacy) " } ,
{ " id " : " deepseek-reasoner " , " label " : " DeepSeek Reasoner (legacy) " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-04-12 07:11:41 +00:00
" nous " : [
2026-04-23 05:56:21 +00:00
{ " id " : " @nous:anthropic/claude-opus-4.6 " , " label " : " Claude Opus 4.6 (via Nous) " } ,
{ " id " : " @nous:anthropic/claude-sonnet-4.6 " , " label " : " Claude Sonnet 4.6 (via Nous) " } ,
{ " id " : " @nous:openai/gpt-5.4-mini " , " label " : " GPT-5.4 Mini (via Nous) " } ,
{ " id " : " @nous:google/gemini-3.1-pro-preview " , " label " : " Gemini 3.1 Pro Preview (via Nous) " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-04-12 07:11:41 +00:00
" zai " : [
2026-06-20 21:22:53 +00:00
{ " id " : " glm-5.2 " , " label " : " GLM-5.2 " } ,
2026-04-12 07:11:41 +00:00
{ " id " : " glm-5.1 " , " label " : " GLM-5.1 " } ,
{ " id " : " glm-5 " , " label " : " GLM-5 " } ,
{ " id " : " glm-5-turbo " , " label " : " GLM-5 Turbo " } ,
{ " id " : " glm-4.7 " , " label " : " GLM-4.7 " } ,
{ " id " : " glm-4.5 " , " label " : " GLM-4.5 " } ,
{ " id " : " glm-4.5-flash " , " label " : " GLM-4.5 Flash " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-04-12 07:11:41 +00:00
" kimi-coding " : [
{ " id " : " moonshot-v1-8k " , " label " : " Moonshot v1 8k " } ,
{ " id " : " moonshot-v1-32k " , " label " : " Moonshot v1 32k " } ,
{ " id " : " moonshot-v1-128k " , " label " : " Moonshot v1 128k " } ,
{ " id " : " kimi-latest " , " label " : " Kimi Latest " } ,
release: v0.50.102–v0.50.108 batch (code blocks, utf-8, image URLs, deletion warning, PermissionError, Docker docs, kimi-k2.5) (#755)
## Batch release: v0.50.102 – v0.50.108
Seven self-built PRs reviewed and approved by @nesquena, now consolidated into a single release branch.
### Included fixes
| Version | PR | What it fixes |
|---|---|---|
| v0.50.102 | #746 | Code blocks lose newlines when not preceded by blank line (fixes #745) |
| v0.50.103 | #743 | `encoding='utf-8'` on `write_text()` in `api/profiles.py` — Windows `.env` detection (fixes #741) |
| v0.50.104 | #735 | Agent `MEDIA:localhost:*` image URLs rewritten to `document.baseURI` — remote users get working images (fixes #642) |
| v0.50.105 | #736 | Profile deletion warning strengthened: "permanently deleted, cannot be undone" across all 6 locales (fixes #637) |
| v0.50.106 | #738 | Catch `PermissionError` in `_signing_key()` — three-container Docker UID mismatch no longer crashes all HTTP requests |
| v0.50.107 | #737 | Docs: three-container UID/GID alignment guide in README + `HERMES_UID`/`HERMES_GID` forwarded in compose (fixes #645) |
| v0.50.108 | #742 | Add `kimi-k2.5` to Kimi/Moonshot provider model list (fixes #740) |
### Testing
- **pytest**: 1510 passed, 1 warning (1 pre-existing unrelated failure excluded)
- **QA harness**: 20/20 passed (`~/WebUI/scripts/run-browser-tests.sh`)
- **Browser**: layout, slash autocomplete width, edit button, image URL rewrite, profile deletion dialog all verified
All PRs reviewed and approved by @nesquena. Ready to merge and tag **v0.50.108**.
2026-04-20 07:26:55 +00:00
{ " id " : " kimi-k2.5 " , " label " : " Kimi K2.5 " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-04-12 07:11:41 +00:00
" minimax " : [
2026-06-03 01:08:01 +00:00
{ " id " : " MiniMax-M3 " , " label " : " MiniMax M3 " } ,
2026-04-12 07:11:41 +00:00
{ " id " : " MiniMax-M2.7 " , " label " : " MiniMax M2.7 " } ,
{ " id " : " MiniMax-M2.7-highspeed " , " label " : " MiniMax M2.7 Highspeed " } ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
] ,
2026-04-29 07:50:32 +00:00
" minimax-cn " : [
2026-06-03 01:08:01 +00:00
{ " id " : " MiniMax-M3 " , " label " : " MiniMax M3 " } ,
2026-04-29 07:50:32 +00:00
{ " id " : " MiniMax-M2.7 " , " label " : " MiniMax M2.7 " } ,
] ,
2026-04-06 21:29:06 +00:00
# GitHub Copilot — model IDs served via the Copilot API
2026-06-30 23:51:10 +00:00
# Fallback ONLY — the live GitHub Copilot catalog
# (hermes_cli.models.provider_model_ids("copilot")) is authoritative and is
# tried first by _read_live_provider_model_ids(). This static list is the
# safety net shown when the live probe fails (cold start / token blip). Keep
# it in sync with the real integrator allowlist so a probe miss never renders
# legacy junk (GPT-4o / gpt-3.5-turbo). Last synced 2026-06-30 from the live
# copilot-4-cli catalog (16 models).
2026-04-12 07:11:41 +00:00
" copilot " : [
2026-06-30 23:51:10 +00:00
{ " id " : " claude-opus-4.8 " , " label " : " Claude Opus 4.8 " } ,
{ " id " : " claude-opus-4.7 " , " label " : " Claude Opus 4.7 " } ,
{ " id " : " claude-opus-4.6 " , " label " : " Claude Opus 4.6 " } ,
{ " id " : " claude-sonnet-5 " , " label " : " Claude Sonnet 5 " } ,
{ " id " : " claude-sonnet-4.6 " , " label " : " Claude Sonnet 4.6 " } ,
{ " id " : " claude-sonnet-4.5 " , " label " : " Claude Sonnet 4.5 " } ,
{ " id " : " claude-haiku-4.5 " , " label " : " Claude Haiku 4.5 " } ,
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
{ " id " : " gpt-5.5 " , " label " : " GPT-5.5 " } ,
2026-04-12 07:11:41 +00:00
{ " id " : " gpt-5.4 " , " label " : " GPT-5.4 " } ,
{ " id " : " gpt-5.4-mini " , " label " : " GPT-5.4 Mini " } ,
2026-06-30 23:51:10 +00:00
{ " id " : " gpt-5.3-codex " , " label " : " GPT-5.3 Codex " } ,
{ " id " : " gpt-5-mini " , " label " : " GPT-5 Mini " } ,
{ " id " : " gemini-3.1-pro-preview " , " label " : " Gemini 3.1 Pro Preview " } ,
{ " id " : " gemini-3.5-flash " , " label " : " Gemini 3.5 Flash " } ,
{ " id " : " gemini-2.5-pro " , " label " : " Gemini 2.5 Pro " } ,
{ " id " : " mai-code-1-flash-picker " , " label " : " MAI Code 1 Flash " } ,
2026-04-12 07:11:41 +00:00
{ " id " : " gpt-4o " , " label " : " GPT-4o " } ,
2026-04-06 21:29:06 +00:00
] ,
2026-05-23 08:00:39 +00:00
# Cursor ACP — models served via Cursor CLI agent acp
" cursor-acp " : [
{ " id " : " cursor/composer-2.5 " , " label " : " Composer 2.5 " } ,
{ " id " : " cursor/composer-2 " , " label " : " Composer 2 " } ,
{ " id " : " cursor/default " , " label " : " Default " } ,
{ " id " : " cursor-acp " , " label " : " Cursor ACP " } ,
] ,
2026-04-14 01:46:11 +00:00
# OpenCode Zen — curated models via opencode.ai/zen (pay-as-you-go credits)
" opencode-zen " : [
{ " id " : " gpt-5.4-pro " , " label " : " GPT-5.4 Pro " } ,
{ " id " : " gpt-5.4 " , " label " : " GPT-5.4 " } ,
{ " id " : " gpt-5.4-mini " , " label " : " GPT-5.4 Mini " } ,
{ " id " : " gpt-5.4-nano " , " label " : " GPT-5.4 Nano " } ,
{ " id " : " gpt-5.3-codex " , " label " : " GPT-5.3 Codex " } ,
{ " id " : " gpt-5.3-codex-spark " , " label " : " GPT-5.3 Codex Spark " } ,
{ " id " : " gpt-5.2 " , " label " : " GPT-5.2 " } ,
{ " id " : " gpt-5.2-codex " , " label " : " GPT-5.2 Codex " } ,
{ " id " : " gpt-5.1 " , " label " : " GPT-5.1 " } ,
{ " id " : " gpt-5.1-codex " , " label " : " GPT-5.1 Codex " } ,
{ " id " : " gpt-5.1-codex-max " , " label " : " GPT-5.1 Codex Max " } ,
{ " id " : " gpt-5.1-codex-mini " , " label " : " GPT-5.1 Codex Mini " } ,
{ " id " : " gpt-5 " , " label " : " GPT-5 " } ,
{ " id " : " gpt-5-codex " , " label " : " GPT-5 Codex " } ,
{ " id " : " gpt-5-nano " , " label " : " GPT-5 Nano " } ,
2026-04-30 04:06:30 +00:00
{ " id " : " claude-opus-4-7 " , " label " : " Claude Opus 4.7 " } ,
2026-04-14 01:46:11 +00:00
{ " id " : " claude-opus-4-6 " , " label " : " Claude Opus 4.6 " } ,
{ " id " : " claude-opus-4-5 " , " label " : " Claude Opus 4.5 " } ,
{ " id " : " claude-opus-4-1 " , " label " : " Claude Opus 4.1 " } ,
{ " id " : " claude-sonnet-4-6 " , " label " : " Claude Sonnet 4.6 " } ,
{ " id " : " claude-sonnet-4-5 " , " label " : " Claude Sonnet 4.5 " } ,
{ " id " : " claude-sonnet-4 " , " label " : " Claude Sonnet 4 " } ,
{ " id " : " claude-haiku-4-5 " , " label " : " Claude Haiku 4.5 " } ,
{ " id " : " claude-3-5-haiku " , " label " : " Claude 3.5 Haiku " } ,
2026-04-15 23:00:29 +00:00
{ " id " : " gemini-3.1-pro-preview " , " label " : " Gemini 3.1 Pro Preview " } ,
{ " id " : " gemini-3-flash-preview " , " label " : " Gemini 3 Flash Preview " } ,
2026-04-18 17:09:59 +00:00
{ " id " : " gemini-3.1-flash-lite-preview " , " label " : " Gemini 3.1 Flash Lite Preview " } ,
{ " id " : " gemini-2.5-pro " , " label " : " Gemini 2.5 Pro " } ,
{ " id " : " gemini-2.5-flash " , " label " : " Gemini 2.5 Flash " } ,
2026-04-14 01:46:11 +00:00
{ " id " : " glm-5.1 " , " label " : " GLM-5.1 " } ,
{ " id " : " glm-5 " , " label " : " GLM-5 " } ,
{ " id " : " kimi-k2.5 " , " label " : " Kimi K2.5 " } ,
{ " id " : " minimax-m2.5 " , " label " : " MiniMax M2.5 " } ,
{ " id " : " minimax-m2.5-free " , " label " : " MiniMax M2.5 Free " } ,
{ " id " : " nemotron-3-super-free " , " label " : " Nemotron 3 Super Free " } ,
{ " id " : " big-pickle " , " label " : " Big Pickle " } ,
] ,
# OpenCode Go — flat-rate models via opencode.ai/go ($10/month)
" opencode-go " : [
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
{ " id " : " glm-5.1 " , " label " : " GLM-5.1 " } ,
{ " id " : " glm-5 " , " label " : " GLM-5 " } ,
{ " id " : " kimi-k2.5 " , " label " : " Kimi K2.5 " } ,
{ " id " : " kimi-k2.6 " , " label " : " Kimi K2.6 " } ,
{ " id " : " deepseek-v4-pro " , " label " : " DeepSeek V4 Pro " } ,
{ " id " : " deepseek-v4-flash " , " label " : " DeepSeek V4 Flash " } ,
{ " id " : " mimo-v2-pro " , " label " : " MiMo V2 Pro " } ,
{ " id " : " mimo-v2-omni " , " label " : " MiMo V2 Omni " } ,
{ " id " : " mimo-v2.5-pro " , " label " : " MiMo V2.5 Pro " } ,
{ " id " : " mimo-v2.5 " , " label " : " MiMo V2.5 " } ,
{ " id " : " minimax-m2.7 " , " label " : " MiniMax M2.7 " } ,
{ " id " : " minimax-m2.5 " , " label " : " MiniMax M2.5 " } ,
{ " id " : " qwen3.6-plus " , " label " : " Qwen3.6 Plus " } ,
{ " id " : " qwen3.5-plus " , " label " : " Qwen3.5 Plus " } ,
2026-04-14 01:46:11 +00:00
] ,
2026-04-06 21:29:06 +00:00
# 'gemini' is the hermes_cli provider ID for Google AI Studio
2026-04-18 17:09:59 +00:00
# Model IDs are bare — sent directly to:
# https://generativelanguage.googleapis.com/v1beta/openai/chat/completions
2026-04-12 07:11:41 +00:00
" gemini " : [
2026-04-18 17:09:59 +00:00
{ " id " : " gemini-3.1-pro-preview " , " label " : " Gemini 3.1 Pro Preview " } ,
{ " id " : " gemini-3-flash-preview " , " label " : " Gemini 3 Flash Preview " } ,
{ " id " : " gemini-3.1-flash-lite-preview " , " label " : " Gemini 3.1 Flash Lite Preview " } ,
{ " id " : " gemini-2.5-pro " , " label " : " Gemini 2.5 Pro " } ,
{ " id " : " gemini-2.5-flash " , " label " : " Gemini 2.5 Flash " } ,
2026-04-06 21:29:06 +00:00
] ,
2026-04-15 22:27:55 +00:00
# Mistral — prefix used in OpenRouter model IDs (mistralai/mistral-large-latest)
" mistralai " : [
{ " id " : " mistral-large-latest " , " label " : " Mistral Large " } ,
{ " id " : " mistral-small-latest " , " label " : " Mistral Small " } ,
] ,
2026-04-15 22:54:18 +00:00
# Qwen (Alibaba) — prefix used in OpenRouter model IDs (qwen/qwen3-coder)
" qwen " : [
{ " id " : " qwen3-coder " , " label " : " Qwen3 Coder " } ,
{ " id " : " qwen3.6-plus " , " label " : " Qwen3.6 Plus " } ,
] ,
2026-04-28 10:17:19 +00:00
# NVIDIA NIM — NVIDIA's inference platform
" nvidia " : [
{ " id " : " nvidia/nemotron-3-super-120b-a12b " , " label " : " Nemotron 3 Super 120B " } ,
{ " id " : " nvidia/nemotron-3-nano-30b-a3b " , " label " : " Nemotron 3 Nano 30B " } ,
{ " id " : " nvidia/llama-3.3-nemotron-super-49b-v1.5 " , " label " : " Llama 3.3 Nemotron Super 49B " } ,
{ " id " : " qwen/qwen3-next-80b-a3b-instruct " , " label " : " Qwen3 Next 80B " } ,
] ,
2026-05-10 08:48:37 +00:00
# Xiaomi MiMo — direct API via api.xiaomimimo.com
" xiaomi " : [
{ " id " : " mimo-v2.5-pro " , " label " : " MiMo V2.5 Pro " } ,
{ " id " : " mimo-v2.5 " , " label " : " MiMo V2.5 " } ,
{ " id " : " mimo-v2-pro " , " label " : " MiMo V2 Pro " } ,
{ " id " : " mimo-v2-omni " , " label " : " MiMo V2 Omni " } ,
{ " id " : " mimo-v2-flash " , " label " : " MiMo V2 Flash " } ,
] ,
2026-04-15 22:54:18 +00:00
# xAI — prefix used in OpenRouter model IDs (x-ai/grok-4-20)
" x-ai " : [
2026-04-15 23:00:29 +00:00
{ " id " : " grok-4.20 " , " label " : " Grok 4.20 " } ,
2026-04-15 22:54:18 +00:00
] ,
2026-05-19 02:49:54 +00:00
" xai-oauth " : [
{ " id " : " grok-4.20 " , " label " : " Grok 4.20 " } ,
] ,
2026-05-23 07:56:23 +00:00
# AWS Bedrock — static fallback list; live model list is fetched via
# hermes_cli.models.provider_model_ids("bedrock") when available (#2720).
" bedrock " : [
{ " id " : " global.anthropic.claude-opus-4-7 " , " label " : " Global Anthropic Claude Opus 4.7 " } ,
{ " id " : " global.anthropic.claude-opus-4-6-v1 " , " label " : " Global Anthropic Claude Opus 4.6 " } ,
{ " id " : " global.anthropic.claude-sonnet-4-6 " , " label " : " Global Anthropic Claude Sonnet 4.6 " } ,
{ " id " : " global.anthropic.claude-opus-4-5-20251101-v1:0 " , " label " : " GLOBAL Anthropic Claude Opus 4.5 " } ,
{ " id " : " global.anthropic.claude-sonnet-4-5-20250929-v1:0 " , " label " : " Global Claude Sonnet 4.5 " } ,
{ " id " : " global.anthropic.claude-haiku-4-5-20251001-v1:0 " , " label " : " Global Anthropic Claude Haiku 4.5 " } ,
] ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
}
2026-06-20 21:22:53 +00:00
def _seed_provider_models_from_core ( ) - > None :
""" Enrich existing provider model lists with missing IDs from hermes_cli.
The core ' s _PROVIDER_MODELS is the authoritative curated list of agent-capable
models per provider . The WebUI ' s static dict above is a display-oriented copy
( with { id , label } entries ) that can go stale when new models are added to the
core without a matching WebUI update . This function bridges the gap by
injecting any missing model IDs from the core into * * existing * * WebUI provider
entries .
Constrains seeding to providers already in the WebUI catalog — does NOT add
brand - new providers . Adding new vendors is a maintainer curation decision .
Respects per - provider ID conventions ( e . g . nous uses @nous : - prefixed IDs ) .
Safe to call multiple times ; only missing entries are added . Silently no - ops
if hermes_cli is not importable ( standalone WebUI deployments ) .
Must be called AFTER ` ` _get_label_for_model ` ` is defined ( module - level
invocation is at the bottom of this module , not here ) .
"""
try :
from hermes_cli . models import _PROVIDER_MODELS as _core_pm
except ImportError :
return
# Build a canonical-id → WebUI-key lookup so that providers whose canonical
# form differs between core and WebUI (e.g. core uses "xai" but WebUI
# indexes by "x-ai") merge into the existing entry instead of creating a
# duplicate (#4413).
_webui_key_by_canonical : dict [ str , str ] = { }
for _wk in _PROVIDER_MODELS :
try :
_canon = _resolve_provider_alias ( _wk )
except Exception :
_canon = _wk
if _canon not in _webui_key_by_canonical :
_webui_key_by_canonical [ _canon ] = _wk
for provider_id , core_models in _core_pm . items ( ) :
if not isinstance ( core_models , list ) :
continue
# Resolve the core's provider_id to the WebUI's key for this provider.
webui_key = provider_id
webui_list = _PROVIDER_MODELS . get ( provider_id )
if webui_list is None :
try :
_canon_pid = _resolve_provider_alias ( provider_id )
except Exception :
_canon_pid = provider_id
webui_key = _webui_key_by_canonical . get ( _canon_pid , provider_id )
webui_list = _PROVIDER_MODELS . get ( webui_key )
if webui_list is None :
# Provider exists in core but not in the WebUI catalog.
# Do NOT seed — adding new vendors is a maintainer curation
# decision, not something the seeder should do implicitly (#4413).
continue
if not isinstance ( webui_list , list ) :
continue
# Provider exists in both — inject missing model IDs.
# Detect per-provider ID prefix convention (e.g. nous uses @nous:).
# The merge must respect each provider's existing ID format rather
# than injecting the core's raw IDs (#4413).
_existing_ids_raw : list [ str ] = [
( m . get ( " id " ) if isinstance ( m , dict ) else str ( m ) ) or " "
for m in webui_list
if isinstance ( m , dict ) and m . get ( " id " )
]
_prefix = " "
if _existing_ids_raw and all ( i . startswith ( " @ " ) and " : " in i for i in _existing_ids_raw ) :
_prefix = _existing_ids_raw [ 0 ] . split ( " : " , 1 ) [ 0 ] + " : "
def _strip_prefix ( mid : str , prefix : str = _prefix ) - > str :
if prefix and mid . startswith ( prefix ) :
return mid [ len ( prefix ) : ]
return mid
existing_ids = {
_strip_prefix ( mid ) . replace ( " - " , " . " ) . lower ( )
for mid in _existing_ids_raw
}
for mid in core_models :
if not isinstance ( mid , str ) or not mid . strip ( ) :
continue
normed = mid . strip ( ) . replace ( " - " , " . " ) . lower ( )
if normed not in existing_ids :
inject_id = ( _prefix + mid . strip ( ) ) if _prefix else mid . strip ( )
webui_list . append ( {
" id " : inject_id ,
" label " : _get_label_for_model ( mid . strip ( ) , [ ] ) ,
} )
2026-04-22 20:18:02 +00:00
_AMBIENT_GH_CLI_MARKERS = frozenset ( { " gh_cli " , " gh auth token " } )
2026-06-02 10:19:39 +00:00
# Environment variable sources that are auto-detected and should be filtered
# when the token is a classic PAT (ghp_*) that Copilot API doesn't support.
# Note: COPILOT_GITHUB_TOKEN is NOT included here - it's user-specific config.
_AMBIENT_GH_ENV_SOURCES = frozenset ( { " env:github_token " , " env:gh_token " } )
2026-04-22 20:18:02 +00:00
def _is_ambient_gh_cli_entry ( source : str , label : str , key_source : str ) - > bool :
""" True when a credential-pool entry is a seeded gh-cli token rather than
one the user added explicitly . Filter these so Copilot doesn ' t appear in
the dropdown just because ` gh ` is installed on the system .
2026-06-02 06:04:37 +00:00
2026-06-02 10:19:39 +00:00
Also filters GITHUB_TOKEN and GH_TOKEN env var entries , which are
auto - detected from the environment and should not cause Copilot to appear
in the picker when the token is a classic PAT ( ghp_ * ) that Copilot API
doesn ' t support.
Note : COPILOT_GITHUB_TOKEN is NOT filtered - it ' s user-specific config
that should always be respected .
2026-04-22 20:18:02 +00:00
"""
2026-06-02 06:04:37 +00:00
source_lower = source . strip ( ) . lower ( )
2026-04-22 20:18:02 +00:00
return (
2026-06-02 06:04:37 +00:00
source_lower in _AMBIENT_GH_CLI_MARKERS
2026-06-02 10:19:39 +00:00
or source_lower in _AMBIENT_GH_ENV_SOURCES
2026-04-22 20:18:02 +00:00
or label . strip ( ) . lower ( ) == " gh auth token "
or key_source . strip ( ) . lower ( ) == " gh auth token "
)
def _format_ollama_label ( mid : str ) - > str :
""" Turn an Ollama model id (Ollama tag format) into a readable display label.
Examples : ' kimi-k2.5 ' → ' Kimi K2.5 ' , ' qwen3-vl:235b-instruct ' → ' Qwen3 VL (235B Instruct) '
"""
name_part , _ , variant = mid . partition ( " : " )
def _fmt ( s : str ) - > str :
tokens = s . replace ( " - " , " " ) . replace ( " _ " , " " ) . split ( )
out = [ ]
for t in tokens :
alpha_only = t . replace ( " . " , " " )
if alpha_only . isalpha ( ) and len ( t ) < = 3 :
out . append ( t . upper ( ) ) # short acronym: glm → GLM, vl → VL, gpt → GPT
elif alpha_only . isalnum ( ) and alpha_only and alpha_only [ 0 ] . isdigit ( ) :
out . append ( t . upper ( ) ) # size param: 235b → 235B, 1t → 1T
else :
out . append ( t [ 0 ] . upper ( ) + t [ 1 : ] if t else t ) # capitalize: kimi → Kimi
return " " . join ( out )
label = _fmt ( name_part )
if variant :
label + = f " ( { _fmt ( variant ) } ) "
return label
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
def _format_nous_label ( mid : str ) - > str :
""" Turn a Nous Portal model id into a readable display label.
Nous IDs are ` ` < vendor > / < model > [ : < variant > ] ` ` ( e . g . ` ` anthropic / claude - opus - 4.7 ` ` ) ;
drop the vendor namespace , prettify the model name with the same token
rules as : func : ` _format_ollama_label ` ( short acronyms uppercase , size
suffixes uppercase , capitalize the rest ) , then append ` ` " (via Nous) " ` `
so the entry is visually distinct from same - named models in other
provider groups ( e . g . direct Anthropic ) .
2026-05-03 18:11:56 +00:00
Examples ( matches the helper ' s actual output — labels are produced by
: func : ` _format_ollama_label ` ' s token rules, so 3-letter tokens like
` ` GPT ` ` and ` ` PRO ` ` render uppercase ) : :
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
anthropic / claude - opus - 4.7 - > Claude Opus 4.7 ( via Nous )
openai / gpt - 5.4 - mini - > GPT 5.4 Mini ( via Nous )
2026-05-03 18:11:56 +00:00
google / gemini - 3.1 - pro - preview - > Gemini 3.1 PRO Preview ( via Nous )
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
moonshotai / kimi - k2 .6 - > Kimi K2 .6 ( via Nous )
qwen / qwen3 .5 - plus - 02 - 15 - > Qwen3 .5 Plus 02 15 ( via Nous )
2026-05-03 18:11:56 +00:00
nvidia / nemotron - 3 - super - 120 b - a12b - > Nemotron 3 Super 120 B A12b ( via Nous )
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
minimax / minimax - m2 .5 : free - > MiniMax M2 .5 ( Free ) ( via Nous )
"""
name_part = mid . split ( " / " , 1 ) [ - 1 ] if " / " in mid else mid
# MiniMax-CN ids come back lowercase on the live wire (`minimax-m2.5`) but
# the curated label convention is mixed-case "MiniMax M2.5" — match that.
if name_part . lower ( ) . startswith ( " minimax " ) :
name_part = " MiniMax " + name_part [ len ( " minimax " ) : ]
base = _format_ollama_label ( name_part )
return f " { base } (via Nous) "
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
# Soft cap on how many Nous Portal models surface in the picker dropdown.
# Above this count, _build_nous_featured_set() trims the visible list to
# ~_NOUS_FEATURED_TARGET entries; the full catalog is still returned to the
# client under ``extra_models`` so /model autocomplete covers everything.
# Caps reflect human scannability — a 25-row dropdown is the practical UX
# ceiling, and per-vendor sampling at 15 keeps the flagship shape visible
# without one vendor dominating.
_NOUS_FEATURED_THRESHOLD = 25
_NOUS_FEATURED_TARGET = 15
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_MODEL_PICKER_OVERFLOW_THRESHOLD = _NOUS_FEATURED_THRESHOLD
_MODEL_PICKER_VISIBLE_TARGET = _NOUS_FEATURED_TARGET
_OPENROUTER_FREE_TIER_AUGMENT_CAP = 30
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
# Vendor-prefix priority order for featured selection. Lower index = picked
# earlier when sampling the live catalog. Reflects which vendors users have
# historically reached for first via Nous Portal (driven by the curated
# static list maintained in _PROVIDER_MODELS["nous"] and Discord feedback).
_NOUS_VENDOR_PRIORITY = (
" anthropic " , " openai " , " google " , " moonshotai " , " z-ai " ,
" minimax " , " qwen " , " x-ai " , " deepseek " , " stepfun " ,
" xiaomi " , " tencent " , " nvidia " , " arcee-ai " ,
)
def _build_nous_featured_set (
live_ids : list [ str ] ,
* ,
selected_model_id : str | None = None ,
target : int = _NOUS_FEATURED_TARGET ,
) - > tuple [ list [ str ] , list [ str ] ] :
""" Trim a Nous Portal catalog into a (featured, extras) split.
` ` featured ` ` is what the picker dropdown renders . ` ` extras ` ` is everything
else — kept available so the slash - command ` / model ` autocomplete and the
` ` _dynamicModelLabels ` ` map cover the full catalog .
Selection rules ( in order , deterministic ) :
1. Always include the user ' s currently-selected model if it ' s in the
catalog ( preserves selection stickiness — 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 —
those four are explicitly maintained as flagship picks .
3. Top up to ` ` target ` ` by walking ` ` _NOUS_VENDOR_PRIORITY ` ` round - robin
( one model per vendor each pass ) so no vendor monopolises the slot
budget . Within a vendor , the original ` ` live_ids ` ` order is preserved
— that ' s the order Nous Portal returned, which approximates recency.
Returns ` ` ( featured_ids , extras_ids ) ` ` — both lists are subsets of
` ` live_ids ` ` with disjoint membership and union equal to ` ` live_ids ` ` .
For catalogs ≤ ` ` _NOUS_FEATURED_THRESHOLD ` ` entries the function is a
no - op : ` ` featured == live_ids ` ` , ` ` extras == [ ] ` ` .
"""
if not live_ids :
return [ ] , [ ]
if len ( live_ids ) < = _NOUS_FEATURED_THRESHOLD :
return list ( live_ids ) , [ ]
chosen : list [ str ] = [ ] # preserves insertion order
chosen_set : set [ str ] = set ( )
def _add ( mid : str ) - > None :
if mid and mid not in chosen_set :
chosen . append ( mid )
chosen_set . add ( mid )
# Rule 1: sticky selection. Strip "@nous:" prefix if present so we can
# match against the live id space (which is bare "vendor/model").
if selected_model_id :
sel = selected_model_id
if sel . startswith ( " @nous: " ) :
sel = sel [ len ( " @nous: " ) : ]
if sel in live_ids :
_add ( sel )
# Rule 2: curated flagships. Extract the bare ids from the static list
# entries (which are stored as "@nous:vendor/model").
for static in _PROVIDER_MODELS . get ( " nous " , [ ] ) :
sid = static . get ( " id " , " " )
if sid . startswith ( " @nous: " ) :
sid = sid [ len ( " @nous: " ) : ]
if sid in live_ids :
_add ( sid )
# Rule 3: vendor-priority round-robin top-up.
by_vendor : dict [ str , list [ str ] ] = { }
for mid in live_ids :
if mid in chosen_set :
continue
vendor = mid . split ( " / " , 1 ) [ 0 ] if " / " in mid else " "
by_vendor . setdefault ( vendor , [ ] ) . append ( mid )
# Walk vendors in priority order, then any leftover vendors alphabetically.
priority = list ( _NOUS_VENDOR_PRIORITY )
leftover = sorted ( v for v in by_vendor if v not in set ( priority ) )
vendor_order = priority + leftover
# Round-robin: one model per vendor per pass until we hit the target or
# exhaust every bucket.
while len ( chosen ) < target :
added_this_pass = 0
for vendor in vendor_order :
if len ( chosen ) > = target :
break
bucket = by_vendor . get ( vendor )
if not bucket :
continue
_add ( bucket . pop ( 0 ) )
added_this_pass + = 1
if added_this_pass == 0 :
break # all buckets empty
# Anything not chosen becomes extras (full-catalog completion surface).
extras = [ m for m in live_ids if m not in chosen_set ]
return chosen , extras
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
def _strip_picker_provider_hint ( model_id : str ) - > str :
mid = str ( model_id or " " ) . strip ( )
if mid . startswith ( " @ " ) and " : " in mid :
return mid [ mid . index ( " : " ) + 1 : ]
return mid
def _model_matches_picker_selection (
model_id : str ,
selected_model_id : str | None ,
provider_id : str | None = None ,
) - > bool :
selected = str ( selected_model_id or " " ) . strip ( )
candidate = str ( model_id or " " ) . strip ( )
if not selected or not candidate :
return False
if candidate == selected :
return True
selected_bare = _strip_picker_provider_hint ( selected )
candidate_bare = _strip_picker_provider_hint ( candidate )
if selected_bare != candidate_bare :
return False
selected_provider = " "
if selected . startswith ( " @ " ) and " : " in selected :
selected_provider = selected [ 1 : selected . index ( " : " ) ] . lower ( )
candidate_provider = str ( provider_id or " " ) . strip ( ) . lower ( )
if candidate . startswith ( " @ " ) and " : " in candidate :
candidate_provider = candidate [ 1 : candidate . index ( " : " ) ] . lower ( )
return not selected_provider or not candidate_provider or selected_provider == candidate_provider
def _split_picker_overflow_models (
ordered_models : list [ dict ] ,
* ,
selected_model_id : str | None = None ,
provider_id : str | None = None ,
threshold : int = _MODEL_PICKER_OVERFLOW_THRESHOLD ,
target : int = _MODEL_PICKER_VISIBLE_TARGET ,
) - > tuple [ list [ dict ] , list [ dict ] ] :
""" Split an ordered picker catalog into visible rows plus an overflow tail. """
models = [ copy . deepcopy ( m ) for m in ( ordered_models or [ ] ) if isinstance ( m , dict ) and m . get ( " id " ) ]
if len ( models ) < = threshold :
return models , [ ]
visible = models [ : target ]
extras = models [ target : ]
if not selected_model_id :
return visible , extras
if any ( _model_matches_picker_selection ( m . get ( " id " , " " ) , selected_model_id , provider_id ) for m in visible ) :
return visible , extras
for idx , model in enumerate ( extras ) :
if not _model_matches_picker_selection ( model . get ( " id " , " " ) , selected_model_id , provider_id ) :
continue
displaced = visible [ - 1 ]
visible [ - 1 ] = model
extras [ idx ] = displaced
break
return visible , extras
2026-04-22 20:18:02 +00:00
def _apply_provider_prefix (
raw_models : list [ dict ] ,
provider_id : str ,
active_provider : str | None ,
) - > list [ dict ] :
""" Return *raw_models* with @provider: prefixes applied when needed.
Prefixing is skipped when ( a ) the provider is already the active one , or
( b ) a model id already starts with ' @ ' or contains ' / ' ( already routable ) .
"""
_active = ( active_provider or " " ) . lower ( )
if not _active or provider_id == _active :
return list ( raw_models )
result = [ ]
for m in raw_models :
mid = m [ " id " ]
if mid . startswith ( " @ " ) or " / " in mid :
result . append ( { " id " : mid , " label " : m [ " label " ] } )
else :
result . append ( { " id " : f " @ { provider_id } : { mid } " , " label " : m [ " label " ] } )
return result
2026-04-28 18:42:52 +00:00
def _deduplicate_model_ids ( groups : list [ dict ] ) - > None :
""" Ensure every model ID across groups is globally unique.
2026-04-30 15:24:35 +00:00
When multiple providers expose the same model ID ( either bare names like
` ` gpt - 5.4 ` ` or slash - qualified IDs like ` ` google / gemma - 4 - 27 b ` ` ) , the
dropdown cannot distinguish them . This post - process detects such
collisions and prefixes colliding entries with ` ` @provider_id : ` ` so the
frontend can treat them as distinct options .
The first occurrence ( in provider - id order ) is left unchanged for backward
compatibility with sessions that already store the original bare / slash
model name . If that provider is later removed from the config , the next
cache rebuild re - runs dedup — the remaining provider becomes the sole
occurrence and is left unchanged , so the session still matches .
2026-04-28 19:16:44 +00:00
2026-04-28 19:29:19 +00:00
. . note : :
2026-04-30 15:24:35 +00:00
The " first occurrence wins " rule means the unchanged ID is not stable
2026-04-28 19:29:19 +00:00
across config changes ( adding , removing , or reordering providers ) .
This is acceptable because the dedup runs on every cache rebuild ,
2026-04-30 15:24:35 +00:00
so sessions always resolve to the current canonical unchanged ID .
2026-04-28 19:29:19 +00:00
2026-04-28 19:16:44 +00:00
The ` ` @provider_id : model ` ` format is consistent with the existing
` ` _apply_provider_prefix ( ) ` ` function and is handled by
2026-04-28 19:29:19 +00:00
` ` resolve_model_provider ( ) ` ` ( rsplits on the last ` ` : ` ` to handle
provider_ids that themselves contain ` ` : ` ` ) .
2026-04-28 18:42:52 +00:00
Operates in - place on * groups * .
"""
if not groups :
return
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
# Collect {model_id: [(group_idx, bucket_name, model_idx), ...]} in
# alphabetical provider_id order so that the "first occurrence stays
# unchanged" rule is deterministic across config edits
# (adding/removing/reordering providers). Include ``extra_models`` too:
# slash-command resolution and picker filtering consume the full catalog.
2026-04-29 05:06:34 +00:00
sorted_group_indices = sorted (
range ( len ( groups ) ) ,
key = lambda i : groups [ i ] . get ( " provider_id " , " " ) ,
)
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
id_map : dict [ str , list [ tuple [ int , str , int ] ] ] = { }
2026-04-29 05:06:34 +00:00
for gi in sorted_group_indices :
group = groups [ gi ]
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
for bucket_name in ( " models " , " extra_models " ) :
for mi , model in enumerate ( group . get ( bucket_name , [ ] ) or [ ] ) :
mid = str ( model . get ( " id " , " " ) or " " ) . strip ( )
# Skip IDs that are already provider-qualified.
if not mid or mid . startswith ( " @ " ) :
continue
id_map . setdefault ( mid , [ ] ) . append ( ( gi , bucket_name , mi ) )
2026-04-28 18:42:52 +00:00
2026-04-30 15:24:35 +00:00
# For any ID appearing in 2+ groups, prefix all but the first occurrence.
2026-04-28 19:29:19 +00:00
# This handles N>2 providers correctly: the loop iterates over all
# occurrences after the first, prefixing each with its own provider_id.
2026-04-30 15:24:35 +00:00
for original_id , locations in id_map . items ( ) :
2026-04-28 18:42:52 +00:00
if len ( locations ) < 2 :
continue
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
for gi , bucket_name , mi in locations [ 1 : ] :
2026-04-28 18:42:52 +00:00
group = groups [ gi ]
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
model = group [ bucket_name ] [ mi ]
2026-04-28 18:42:52 +00:00
pid = group . get ( " provider_id " , " " )
2026-04-30 15:24:35 +00:00
model [ " id " ] = f " @ { pid } : { original_id } "
2026-04-28 18:42:52 +00:00
provider_name = group . get ( " provider " , pid )
2026-04-30 15:24:35 +00:00
if model . get ( " label " ) != original_id :
2026-04-28 18:42:52 +00:00
model [ " label " ] = f " { model [ ' label ' ] } ( { provider_name } ) "
else :
2026-04-30 15:24:35 +00:00
model [ " label " ] = f " { original_id } ( { provider_name } ) "
2026-04-28 18:42:52 +00:00
fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625)
Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived
handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller).
Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE +
5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the
kernel tear sockets down on flaky networks before the app sent its first
keepalive byte — drops at ~10s during long thinking phases. New named
constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the
inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't
re-introduce the misalignment.
Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger.
Without it, any narrow race between the streaming thread clearing
pending_user_message and STREAMS.pop(stream_id) produces a false-positive
'Previous turn did not complete.' marker on a turn that finished correctly
(reproducible after every command-approval turn). Defense-in-depth, not
the root-cause fix — the actual streaming-thread leak path is tracked
separately. Falsy pending_started_at (legacy sidecars) treated as
'old enough' so legitimate legacy-data recovery still works. Plus
logger.warning telemetry on every legitimate repair so the next batch of
user reports tells us whether the underlying race still fires.
Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM,
TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style
model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New
_LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/
RFC1918 heuristic — either signal triggers no-strip. Backward compat
preserved for OpenAI-compatible proxies on public hosts (LiteLLM at
litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the
existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom
rule for loopback hosts (see api/config.py and test_model_resolver.py
docstring update). Reported by @akarichan8231 in Discord on 2026-05-04.
42 regression tests across:
tests/test_issue1623_sse_heartbeat_alignment.py (3)
tests/test_issue1624_repair_stale_pending_grace.py (9)
tests/test_issue1625_local_server_model_id_preservation.py (30)
4142 -> 4184 passing. 0 regressions.
2026-05-04 16:31:32 +00:00
# ── Local-server provider preservation (#1625) ─────────────────────────────
#
# LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI etc. are inference servers,
# not OpenAI-compatible proxies. They register models under their FULL path
# as the registry key (the HuggingFace-style "namespace/model" id, e.g.
# "qwen/qwen3.6-27b"). Stripping the namespace prefix would cause a registry
# miss and the server loads a brand-new instance with default settings,
# silently ignoring the user's tuned context length / parallel slots.
#
# This is distinct from OpenAI-compatible proxies (LiteLLM, OpenRouter relays)
# where stripping "openai/gpt-5.4" → "gpt-5.4" is the correct behavior.
#
# Detection has two layers:
# 1. Static set of known local-server provider names (canonical + common
# custom-provider naming).
# 2. Loopback / private-host base_url heuristic: an OpenAI-compatible URL
# pointing at 127.0.0.1, localhost, or a private IP block is almost
# certainly a local model server, regardless of the provider name.
# Reuses the same private-IP detection logic used elsewhere in
# api/config.py for SSRF host trust.
_LOCAL_SERVER_PROVIDERS = {
2026-05-04 16:48:22 +00:00
" lmstudio " , # canonical (in hermes_cli.models.CANONICAL_PROVIDERS)
" lm-studio " , # alias used in some custom_providers configs (#1625 Opus NIT)
" ollama " , # via custom_providers, common pattern
" llamacpp " , # via custom_providers
" llama-cpp " , # alias
" vllm " , # via custom_providers
" tabby " , # via custom_providers (TabbyAPI)
" tabbyapi " , # alias
" koboldcpp " , # local llama.cpp UI fork
" textgen " , # text-generation-webui (oobabooga) OpenAI-compat extension
" localai " , # LocalAI project (#1625 Opus NIT)
fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625)
Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived
handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller).
Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE +
5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the
kernel tear sockets down on flaky networks before the app sent its first
keepalive byte — drops at ~10s during long thinking phases. New named
constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the
inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't
re-introduce the misalignment.
Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger.
Without it, any narrow race between the streaming thread clearing
pending_user_message and STREAMS.pop(stream_id) produces a false-positive
'Previous turn did not complete.' marker on a turn that finished correctly
(reproducible after every command-approval turn). Defense-in-depth, not
the root-cause fix — the actual streaming-thread leak path is tracked
separately. Falsy pending_started_at (legacy sidecars) treated as
'old enough' so legitimate legacy-data recovery still works. Plus
logger.warning telemetry on every legitimate repair so the next batch of
user reports tells us whether the underlying race still fires.
Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM,
TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style
model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New
_LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/
RFC1918 heuristic — either signal triggers no-strip. Backward compat
preserved for OpenAI-compatible proxies on public hosts (LiteLLM at
litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the
existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom
rule for loopback hosts (see api/config.py and test_model_resolver.py
docstring update). Reported by @akarichan8231 in Discord on 2026-05-04.
42 regression tests across:
tests/test_issue1623_sse_heartbeat_alignment.py (3)
tests/test_issue1624_repair_stale_pending_grace.py (9)
tests/test_issue1625_local_server_model_id_preservation.py (30)
4142 -> 4184 passing. 0 regressions.
2026-05-04 16:31:32 +00:00
}
2026-05-08 00:41:55 +00:00
def _is_local_server_provider ( provider_id : str ) - > bool :
""" True when provider_id names a local model server.
Named custom providers resolve to ` ` custom : < slug > ` ` . Treat those as local
when the bare slug is one of the known local - server provider names too .
"""
provider = str ( provider_id or " " ) . strip ( ) . lower ( )
if provider in _LOCAL_SERVER_PROVIDERS :
return True
if provider . startswith ( " custom: " ) :
return provider . removeprefix ( " custom: " ) in _LOCAL_SERVER_PROVIDERS
return False
2026-06-10 08:56:01 +00:00
def _is_first_party_model ( provider_id : str , model_id : str ) - > bool :
""" True when ``model_id`` is listed in ``provider_id`` ' s own static catalog.
Used to tell a * redundant * first - party prefix from an * intrinsic * routing
prefix on a bare ` ` custom ` ` endpoint . ` ` openai / gpt - 5.4 ` ` → gpt - 5.4 is a real
OpenAI model , so ` ` openai / ` ` is a redundant leftover and strippable ( #433).
But ` ` bedrock / opus - 4 - 6 ` ` → opus - 4 - 6 is NOT in bedrock ' s first-party catalog
( those ids look like ` ` global . anthropic . claude - … ` ` ) , so ` ` bedrock / ` ` is a
vendor - routing segment a proxy needs whole ( #3872). Returns False on any
unknown provider or empty model so callers preserve the id .
"""
provider = str ( provider_id or " " ) . strip ( ) . lower ( )
model = str ( model_id or " " ) . strip ( )
if not provider or not model :
return False
catalog = _PROVIDER_MODELS . get ( provider )
if not isinstance ( catalog , list ) :
return False
return any (
isinstance ( entry , dict ) and entry . get ( " id " ) == model
for entry in catalog
)
fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625)
Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived
handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller).
Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE +
5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the
kernel tear sockets down on flaky networks before the app sent its first
keepalive byte — drops at ~10s during long thinking phases. New named
constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the
inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't
re-introduce the misalignment.
Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger.
Without it, any narrow race between the streaming thread clearing
pending_user_message and STREAMS.pop(stream_id) produces a false-positive
'Previous turn did not complete.' marker on a turn that finished correctly
(reproducible after every command-approval turn). Defense-in-depth, not
the root-cause fix — the actual streaming-thread leak path is tracked
separately. Falsy pending_started_at (legacy sidecars) treated as
'old enough' so legitimate legacy-data recovery still works. Plus
logger.warning telemetry on every legitimate repair so the next batch of
user reports tells us whether the underlying race still fires.
Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM,
TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style
model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New
_LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/
RFC1918 heuristic — either signal triggers no-strip. Backward compat
preserved for OpenAI-compatible proxies on public hosts (LiteLLM at
litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the
existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom
rule for loopback hosts (see api/config.py and test_model_resolver.py
docstring update). Reported by @akarichan8231 in Discord on 2026-05-04.
42 regression tests across:
tests/test_issue1623_sse_heartbeat_alignment.py (3)
tests/test_issue1624_repair_stale_pending_grace.py (9)
tests/test_issue1625_local_server_model_id_preservation.py (30)
4142 -> 4184 passing. 0 regressions.
2026-05-04 16:31:32 +00:00
def _base_url_points_at_local_server ( base_url : str ) - > bool :
""" True if base_url ' s host is a loopback or private IP (likely local server).
Reuses ipaddress . is_loopback / is_private / is_link_local — the same
heuristic used in the ` api / config . py ` SSRF / credential - routing code .
Errors ( DNS failure , malformed URL ) return False so callers fall back to
the static - provider - name check .
"""
if not base_url :
return False
try :
from urllib . parse import urlparse
import ipaddress
host = ( urlparse ( base_url ) . hostname or " " ) . lower ( )
if not host :
return False
# Plain-text "localhost" doesn't ipaddress-parse but is unambiguous.
if host in ( " localhost " , " ip6-localhost " , " ip6-loopback " ) :
return True
try :
addr = ipaddress . ip_address ( host )
except ValueError :
# Not an IP literal — could be a hostname like "ollama.internal".
# Don't try DNS resolution here (slow + ambient): only IP literals
# and the `localhost` alias get the no-strip treatment via this path.
return False
return addr . is_loopback or addr . is_private or addr . is_link_local
except Exception :
return False
2026-05-09 08:16:32 +00:00
def _custom_slug_rest_looks_like_host_port ( rest : str ) - > bool :
""" True when ``custom:<rest>`` is an endpoint-style slug ``host:port``.
WebUI sometimes derives ` ` custom : 10.8 .71 .41 : 8080 ` ` from ` ` base_url ` ` authority .
The #1776 peel must not treat that middle colon as part of an eaten model
segment — otherwise ` ` @custom : 10.8 .71 .41 : 8080 : Qwen3 ` ` wrongly becomes model
` ` 8080 : Qwen3 ` ` .
"""
rest = str ( rest or " " ) . strip ( )
if " : " not in rest :
return False
host , port_s = rest . rsplit ( " : " , 1 )
if not host or " : " in host :
return False
if not port_s . isdigit ( ) :
return False
try :
port_n = int ( port_s )
except ValueError :
return False
if not ( 1 < = port_n < = 65535 ) :
return False
try :
import ipaddress
ipaddress . ip_address ( host )
return True
except ValueError :
pass
hl = host . lower ( )
if hl == " localhost " :
return True
# Typical DNS hostname used as proxy slug (contains at least one label dot).
if " . " in host :
return True
return False
2026-05-09 19:20:01 +00:00
def _get_provider_base_url ( provider_id ) :
2026-05-11 05:59:59 +00:00
""" Look up the configured base_url for a provider (e.g. lmstudio).
Checks two locations , in order :
1. ` ` cfg [ " providers " ] [ < provider_id > ] [ " base_url " ] ` ` — the explicit
per - provider override .
2. ` ` cfg [ " model " ] [ " base_url " ] ` ` — falls back here when
` ` cfg [ " model " ] [ " provider " ] == provider_id ` ` . This is the historical
shape ( the model block carries both the active provider AND the
base URL for that provider in a single record ) .
Returns the URL stripped of trailing ` ` / ` ` if configured , otherwise None .
"""
2026-06-11 20:36:53 +00:00
prov_cfg = _get_provider_cfg ( provider_id )
2026-05-11 05:59:59 +00:00
explicit = ( prov_cfg . get ( " base_url " ) or " " ) . strip ( ) . rstrip ( " / " )
if explicit :
return explicit
model_cfg = cfg . get ( " model " , { } ) or { }
if isinstance ( model_cfg , dict ) :
model_provider = str ( model_cfg . get ( " provider " ) or " " ) . strip ( ) . lower ( )
if model_provider == str ( provider_id ) . strip ( ) . lower ( ) :
model_base = ( model_cfg . get ( " base_url " ) or " " ) . strip ( ) . rstrip ( " / " )
if model_base :
return model_base
return None
2026-05-09 19:20:01 +00:00
2026-06-11 20:36:53 +00:00
def _get_providers_cfg ( ) - > dict :
providers_cfg = cfg . get ( " providers " )
return providers_cfg if isinstance ( providers_cfg , dict ) else { }
def _get_provider_cfg ( provider_id ) - > dict :
provider_cfg = _get_providers_cfg ( ) . get ( provider_id , { } )
return provider_cfg if isinstance ( provider_cfg , dict ) else { }
2026-04-05 06:30:20 +00:00
def resolve_model_provider ( model_id : str ) - > tuple :
2026-04-06 21:10:26 +00:00
""" Resolve model name, provider, and base_url for AIAgent.
2026-04-02 05:56:34 +00:00
2026-04-06 21:10:26 +00:00
Model IDs from the dropdown can be in several formats :
2026-04-10 01:23:40 +00:00
- ' claude-sonnet-4.6 ' ( bare name , uses config default provider )
- ' anthropic/claude-sonnet-4.6 ' ( OpenRouter - style provider / model )
- ' @minimax:MiniMax-M2.7 ' ( explicit provider hint from dropdown )
2026-04-02 05:56:34 +00:00
2026-04-06 21:10:26 +00:00
The @provider : model format is used for models from non - default provider
groups in the dropdown , so we can route them through the correct provider
via resolve_runtime_provider ( requested = provider ) instead of the default .
2026-04-02 06:15:04 +00:00
2026-04-10 01:23:40 +00:00
Custom OpenAI - compatible endpoints are special : their model IDs often look
like provider / model ( for example ` ` google / gemma - 4 - 26 b - a4b ` ` ) , which would be
mistaken for an OpenRouter model if we only looked at the slash . To avoid
that , first check whether the selected model matches an entry in
config . yaml - > custom_providers and route it through that named custom
provider .
2026-04-02 06:15:04 +00:00
Returns ( model , provider , base_url ) where provider and base_url may be None .
2026-04-02 05:56:34 +00:00
"""
config_provider = None
2026-04-02 06:15:04 +00:00
config_base_url = None
2026-04-12 07:11:41 +00:00
model_cfg = cfg . get ( " model " , { } )
2026-04-02 05:56:34 +00:00
if isinstance ( model_cfg , dict ) :
2026-04-12 07:11:41 +00:00
config_base_url = model_cfg . get ( " base_url " )
2026-05-07 13:40:23 +00:00
config_provider = _resolve_configured_provider_id (
model_cfg . get ( " provider " ) ,
cfg ,
base_url = config_base_url ,
2026-05-07 17:07:48 +00:00
resolve_alias = False ,
2026-05-07 13:40:23 +00:00
)
2026-04-02 05:56:34 +00:00
2026-05-01 05:29:42 +00:00
# Heal legacy ``provider: local`` entries (written by WebUI < v0.50.252)
# at read time. ``local`` is not a registered provider, so passing it
# downstream raises a ``LOCAL_API_KEY`` error from the auxiliary client
# mid-conversation when compression/vision/web-extract fires. Route
# through ``custom`` instead — it takes the ``no-key-required``
# OpenAI-compat path that local servers (Ollama, LM Studio, llama.cpp,
# vLLM, TabbyAPI) actually use. See #1384.
if isinstance ( config_provider , str ) and config_provider . strip ( ) . lower ( ) == " local " :
config_provider = " custom "
2026-04-12 07:11:41 +00:00
model_id = ( model_id or " " ) . strip ( )
2026-04-02 05:56:34 +00:00
if not model_id :
2026-04-02 06:15:04 +00:00
return model_id , config_provider , config_base_url
2026-04-02 05:56:34 +00:00
2026-04-10 01:23:40 +00:00
# Custom providers declared in config.yaml should win over slash-based
# OpenRouter heuristics. Their model IDs commonly contain '/' too.
2026-05-10 17:49:12 +00:00
# However, when the active provider is an explicit non-custom provider and
# the requested model_id is the configured default model, that active
# provider takes precedence over overlapping custom_providers[] entries.
# Otherwise WebUI routes to custom:<name> instead of the intended endpoint
# and can surface a 401 from the wrong provider (#1922).
# For all other cases, preserve custom_providers[] routing for explicitly
# selected custom provider models.
_is_explicit_non_custom_provider = (
config_provider is not None
and config_provider != ' custom '
and not config_provider . startswith ( ' custom: ' )
)
_default_model = model_cfg . get ( ' default ' ) if isinstance ( model_cfg , dict ) else None
2026-05-13 19:13:37 +00:00
# Owns model if it appears in the static catalog for the configured provider.
2026-07-04 07:17:05 +00:00
# _PROVIDER_MODELS is keyed by CANONICAL slug (e.g. 'zai', not the 'z-ai'
# alias a user may write in config), so canonicalise config_provider before
# the lookup — otherwise an aliased active provider gets an empty ownership
# set and _skip_custom_providers guard-2 silently fails, letting another
# providers.<slug>.models entry hijack an active-owned model (#5511).
_canon_config_provider = _canonicalise_provider_id ( config_provider ) if config_provider else " "
2026-05-13 19:13:37 +00:00
_provider_models_set : set [ str ] = set ( )
if (
2026-07-04 07:17:05 +00:00
_canon_config_provider
and _canon_config_provider in _PROVIDER_MODELS
and isinstance ( _PROVIDER_MODELS [ _canon_config_provider ] , list )
2026-05-13 19:13:37 +00:00
) :
_provider_models_set = {
2026-07-04 07:17:05 +00:00
m . get ( ' id ' , ' ' ) for m in _PROVIDER_MODELS [ _canon_config_provider ]
2026-05-13 19:13:37 +00:00
if isinstance ( m , dict ) and isinstance ( m . get ( ' id ' ) , str )
}
2026-07-04 07:28:44 +00:00
# The active provider may be defined ENTIRELY via config.yaml `providers:`
# (no static _PROVIDER_MODELS entry) with its own `models:` allowlist. Fold
# that allowlist into the ownership set too, so the active provider owns its
# own declared models and can't be hijacked by another providers.<slug>
# entry that happens to list the same bare id earlier in config order (#5511).
if _canon_config_provider :
_providers_cfg_own = cfg . get ( ' providers ' , { } )
if isinstance ( _providers_cfg_own , dict ) :
for _slug , _pdef in _providers_cfg_own . items ( ) :
if not isinstance ( _pdef , dict ) :
continue
if _canonicalise_provider_id ( _slug ) != _canon_config_provider :
continue
if _canon_config_provider == " copilot " :
continue # copilot.models is a settings map, not an allowlist
_own_models = _pdef . get ( ' models ' )
if isinstance ( _own_models , list ) :
for _m in _own_models :
_mid = str ( _m . get ( ' id ' ) or ' ' ) if isinstance ( _m , dict ) else str ( _m or ' ' )
if _mid . strip ( ) :
_provider_models_set . add ( _mid . strip ( ) )
elif isinstance ( _own_models , dict ) :
_provider_models_set . update (
str ( k ) . strip ( ) for k in _own_models if isinstance ( k , str ) and str ( k ) . strip ( )
)
2026-05-10 17:49:12 +00:00
_skip_custom_providers = (
_is_explicit_non_custom_provider
2026-05-13 19:13:37 +00:00
and (
# Guard 1: model is the configured default (existing behaviour).
( _default_model is not None and model_id == _default_model )
# Guard 2: model is owned by the configured non-custom provider.
or model_id in _provider_models_set
)
2026-05-10 17:49:12 +00:00
)
custom_providers = cfg . get ( ' custom_providers ' , [ ] )
if isinstance ( custom_providers , list ) and not _skip_custom_providers :
2026-04-10 01:23:40 +00:00
for entry in custom_providers :
if not isinstance ( entry , dict ) :
continue
2026-05-10 17:49:12 +00:00
entry_model = ( entry . get ( ' model ' ) or ' ' ) . strip ( )
entry_name = ( entry . get ( ' name ' ) or ' ' ) . strip ( )
entry_base_url = ( entry . get ( ' base_url ' ) or ' ' ) . strip ( )
2026-05-06 18:05:37 +00:00
entry_model_ids = set ( )
if entry_model :
entry_model_ids . add ( entry_model )
2026-05-10 17:49:12 +00:00
entry_models = entry . get ( ' models ' )
2026-05-06 18:05:37 +00:00
if isinstance ( entry_models , dict ) :
entry_model_ids . update (
key . strip ( )
for key in entry_models . keys ( )
if isinstance ( key , str ) and key . strip ( )
)
if entry_name and model_id in entry_model_ids :
2026-05-11 05:48:46 +00:00
provider_hint = _custom_provider_slug_from_name ( entry_name )
2026-04-10 01:23:40 +00:00
return model_id , provider_hint , entry_base_url or None
2026-07-04 04:42:29 +00:00
# Check user-defined providers (config.yaml → providers:).
# Mirrors the custom_providers scan above — exact match against each
# entry's declared models list (case-sensitive to match custom_providers).
providers_cfg = cfg . get ( ' providers ' , { } )
if isinstance ( providers_cfg , dict ) :
target = model_id . strip ( )
2026-07-04 07:10:47 +00:00
# Honor the same active/default ownership guard as the custom_providers
# scan (_skip_custom_providers, config.py:2535): when the active provider
# explicitly owns this model (it's the configured default or in the
# active provider's model set), another provider's overlapping
# `providers.<slug>.models` entry must NOT hijack routing away from the
# active provider (#5511 gate finding — e.g. active ai-gateway + default
# gpt-5 was being pulled to providers.openai.models.gpt-5). In that case
# restrict the scan to the active provider's own canonical slug.
2026-07-04 07:17:05 +00:00
_active_slug = _canon_config_provider
2026-07-04 04:42:29 +00:00
for slug , pdef in providers_cfg . items ( ) :
if not isinstance ( pdef , dict ) :
continue
2026-07-04 07:06:05 +00:00
# Copilot is the documented exception: `providers.copilot.models` is
# a per-model SETTINGS map (reasoning_effort, limits, etc.), NOT a
# routable allowlist (see the exception at the catalog-build site).
# Scanning it here would let a Copilot per-model settings entry
# hijack that model's routing away from its real provider (#5511).
if _canonicalise_provider_id ( slug ) == " copilot " :
continue
2026-07-04 07:10:47 +00:00
# Ownership guard: when the active provider owns this model, only its
# own providers: entry may match; skip all other slugs.
if _skip_custom_providers and _canonicalise_provider_id ( slug ) != _active_slug :
continue
2026-07-04 04:42:29 +00:00
p_models = pdef . get ( ' models ' )
if isinstance ( p_models , list ) :
for m in p_models :
mid = str ( m . get ( ' id ' ) or ' ' ) if isinstance ( m , dict ) else str ( m or ' ' )
if not mid :
continue
if mid . strip ( ) == target :
p_base_url = str ( pdef . get ( ' base_url ' ) or ' ' ) . strip ( )
return model_id , slug , p_base_url or None
elif isinstance ( p_models , dict ) :
for mid in p_models :
if isinstance ( mid , str ) and mid . strip ( ) == target :
p_base_url = str ( pdef . get ( ' base_url ' ) or ' ' ) . strip ( )
return model_id , slug , p_base_url or None
2026-04-06 21:10:26 +00:00
# @provider:model format — explicit provider hint from the dropdown.
# Route through that provider directly (resolve_runtime_provider will
# resolve credentials in streaming.py).
2026-04-28 19:29:19 +00:00
# Use rsplit to handle provider_ids that contain ':' (e.g. custom:my-key).
# With rsplit, "@custom:my-key:model" → provider="custom:my-key", model="model".
fix: resolve rsplit collision for OpenRouter models with :free/:beta/:thinking suffixes (#1744)
The previous approach of prepending 'openrouter/' to the model ID in the
catalog was incorrect — it only masked the symptom while regressing the
config_provider=openrouter codepath.
The root cause is in resolve_model_provider(): rsplit(':', 1) on
'@openrouter:tencent/hy3-preview:free' yields provider='openrouter:tencent/hy3-preview'
and model='free', because the ':free' suffix collides with the @provider:model
grammar.
Fix: after rsplit, validate that the extracted provider hint is a known
provider (in _PROVIDER_MODELS, _PROVIDER_DISPLAY, or starts with 'custom:').
If not, fall back to split(':', 1) so trailing suffixes stay attached to
the model ID.
This fixes all current and future OR models with colon-suffixed tags
(:free, :beta, :thinking, :nitro, etc.) without catalog changes.
Also adds regression tests for the affected models and edge cases.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-05-06 23:50:00 +00:00
# BUT: model IDs that end in :free / :beta / :thinking collide with the
# rsplit grammar (e.g. "@openrouter:tencent/hy3-preview:free" would split
# into provider="openrouter:tencent/hy3-preview", model="free"). Guard
# against that by falling back to split(":") when the rsplit result is not
# a recognised provider (#1744).
2026-05-07 04:22:19 +00:00
#
# Edge case (#1776): for custom providers with the same suffix
# ("@custom:my-key:some-model:free"), rsplit yields
# provider_hint="custom:my-key:some-model", bare_model="free", and the
# custom-prefix guard below skips the split-fallback. Detect the
2026-05-09 08:16:32 +00:00
# over-split structurally — custom hints normally carry one slug segment
# after ``custom:``. If ``provider_hint`` has extra ``:`` tokens because the
# model ID contained tags like ``:free``, peel one segment back (#1776).
#
# Exception: ``custom:<ip-or-host>:<port>`` is a single logical slug derived
# from OpenAI ``base_url`` authority and contains no eaten model segments.
2026-04-12 07:11:41 +00:00
if model_id . startswith ( " @ " ) and " : " in model_id :
fix: resolve rsplit collision for OpenRouter models with :free/:beta/:thinking suffixes (#1744)
The previous approach of prepending 'openrouter/' to the model ID in the
catalog was incorrect — it only masked the symptom while regressing the
config_provider=openrouter codepath.
The root cause is in resolve_model_provider(): rsplit(':', 1) on
'@openrouter:tencent/hy3-preview:free' yields provider='openrouter:tencent/hy3-preview'
and model='free', because the ':free' suffix collides with the @provider:model
grammar.
Fix: after rsplit, validate that the extracted provider hint is a known
provider (in _PROVIDER_MODELS, _PROVIDER_DISPLAY, or starts with 'custom:').
If not, fall back to split(':', 1) so trailing suffixes stay attached to
the model ID.
This fixes all current and future OR models with colon-suffixed tags
(:free, :beta, :thinking, :nitro, etc.) without catalog changes.
Also adds regression tests for the affected models and edge cases.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-05-06 23:50:00 +00:00
inner = model_id [ 1 : ]
provider_hint , bare_model = inner . rsplit ( " : " , 1 )
2026-05-07 04:22:19 +00:00
if provider_hint . startswith ( " custom: " ) and provider_hint . count ( " : " ) > = 2 :
2026-05-09 08:16:32 +00:00
_slug_rest = provider_hint [ len ( " custom: " ) : ]
if not _custom_slug_rest_looks_like_host_port ( _slug_rest ) :
provider_hint , extra = provider_hint . rsplit ( " : " , 1 )
bare_model = f " { extra } : { bare_model } "
2026-05-07 04:22:19 +00:00
elif ( provider_hint not in _PROVIDER_MODELS
fix: resolve rsplit collision for OpenRouter models with :free/:beta/:thinking suffixes (#1744)
The previous approach of prepending 'openrouter/' to the model ID in the
catalog was incorrect — it only masked the symptom while regressing the
config_provider=openrouter codepath.
The root cause is in resolve_model_provider(): rsplit(':', 1) on
'@openrouter:tencent/hy3-preview:free' yields provider='openrouter:tencent/hy3-preview'
and model='free', because the ':free' suffix collides with the @provider:model
grammar.
Fix: after rsplit, validate that the extracted provider hint is a known
provider (in _PROVIDER_MODELS, _PROVIDER_DISPLAY, or starts with 'custom:').
If not, fall back to split(':', 1) so trailing suffixes stay attached to
the model ID.
This fixes all current and future OR models with colon-suffixed tags
(:free, :beta, :thinking, :nitro, etc.) without catalog changes.
Also adds regression tests for the affected models and edge cases.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-05-06 23:50:00 +00:00
and provider_hint not in _PROVIDER_DISPLAY
and not provider_hint . startswith ( " custom: " ) ) :
provider_hint , bare_model = inner . split ( " : " , 1 )
2026-05-15 19:16:23 +00:00
if (
provider_hint . startswith ( " custom: " )
and config_base_url
and _is_local_server_provider ( config_provider )
and provider_hint . lower ( ) in _custom_endpoint_slugs_for_base_url ( config_base_url )
) :
return bare_model , config_provider , config_base_url
2026-05-09 19:20:01 +00:00
return bare_model , provider_hint , _get_provider_base_url ( provider_hint )
2026-04-06 21:10:26 +00:00
2026-04-12 07:11:41 +00:00
if " / " in model_id :
prefix , bare = model_id . split ( " / " , 1 )
2026-04-05 20:58:37 +00:00
# OpenRouter always needs the full provider/model path (e.g. openrouter/free,
# anthropic/claude-sonnet-4.6). Never strip the prefix for OpenRouter.
2026-04-12 07:11:41 +00:00
if config_provider == " openrouter " :
return model_id , " openrouter " , config_base_url
fix(config): preserve nvidia/ prefix on NVIDIA NIM (closes #2177)
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).
2026-05-13 07:05:57 +00:00
# Portal providers (Nous, OpenCode, NVIDIA NIM) serve models from multiple
# upstream namespaces — check them BEFORE the prefix-strip branch so that
# a model id whose prefix happens to equal the config_provider (e.g.
# nvidia/nemotron-... on NVIDIA NIM) still keeps the full namespaced path.
# The earlier ordering ran this guard AFTER the prefix-strip, so it never
# fired in the prefix==config_provider case, causing HTTP 404 from the
# portal which requires the full provider/model id (#2177; sibling of
# #854 / #894 for Nous, where this guard was originally added).
_PORTAL_PROVIDERS = { " nous " , " opencode-zen " , " opencode-go " , " nvidia " }
if config_provider in _PORTAL_PROVIDERS :
return model_id , config_provider , config_base_url
fix: cross-provider model pick causes Connection lost on non-OpenRouter profiles
Root cause: resolve_model_provider() had a branch:
if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
return bare, prefix, None
When Camanji profile (config_provider='anthropic') picked openai/gpt-5.4-mini
from the OpenRouter dropdown, prefix='openai' matched _PROVIDER_MODELS and
config_provider was not 'openrouter', so it returned ('gpt-5.4-mini', 'openai', None).
The agent then demanded OPENAI_API_KEY directly -- not found -- RuntimeError --
stream crashed -- 'Connection lost'.
Fix: if prefix != config_provider (cross-provider selection), always route through
openrouter with the full provider/model string. Only strip the prefix and call a
direct provider API when the config_provider EXACTLY matches the model prefix.
Cases verified:
openrouter + openai/gpt-5.4-mini -> (openai/gpt-5.4-mini, openrouter) ✓
anthropic + openai/gpt-5.4-mini -> (openai/gpt-5.4-mini, openrouter) ✓ FIXED
anthropic + anthropic/claude-... -> (claude-..., anthropic) ✓
anthropic + claude-sonnet-4-6 bare -> (claude-sonnet-4-6, anthropic) ✓
openrouter + anthropic/claude-... -> (anthropic/claude-..., openrouter) ✓
Tests: 426 passed, 0 failed.
2026-04-03 20:23:25 +00:00
# If prefix matches config provider exactly, strip it and use that provider directly.
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
2026-04-02 05:56:34 +00:00
if config_provider and prefix == config_provider :
2026-04-02 06:15:04 +00:00
return bare , config_provider , config_base_url
2026-05-01 05:23:47 +00:00
# The OpenAI Codex provider uses a real base_url, but its default
# ChatGPT endpoint cannot serve OpenRouter-style provider/model IDs.
# Keep that narrow exception before the custom endpoint protection so
# selecting openai/gpt-5.5 from OpenRouter under active Codex still
# routes through OpenRouter. Other base_url-backed real providers may be
# custom/proxy endpoints, so they must fall through to the branch below.
if (
config_provider == " openai-codex "
and str ( config_base_url or " " ) . strip ( ) . rstrip ( " / " )
== " https://chatgpt.com/backend-api/codex "
and prefix in _PROVIDER_MODELS
and prefix != config_provider
) :
return model_id , " openrouter " , None
2026-05-17 19:22:06 +00:00
# Cross-provider via custom_providers: if the prefix matches a named custom
# provider entry (e.g. "ollama-local/glm-4.7-flash:q4_k_m"), route through it
# instead of falling back to the default config provider. MUST come BEFORE
# the config_base_url branch because many providers have a base_url set.
if prefix and config_provider and prefix != config_provider :
_custom_cfg = cfg . get ( " custom_providers " , [ ] )
if isinstance ( _custom_cfg , list ) :
for _entry in _custom_cfg :
if isinstance ( _entry , dict ) and _entry . get ( " name " , " " ) . strip ( ) == prefix :
_slug = _custom_provider_slug_from_name ( prefix )
_base = ( _entry . get ( " base_url " ) or " " ) . strip ( )
return model_id , _slug , _base or None
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes #230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes #248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes #247 Fixes #246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
# If a custom endpoint base_url is configured, don't reroute through OpenRouter
# just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
# The user has explicitly pointed at a base_url, so trust their routing config.
if config_base_url :
fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625)
Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived
handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller).
Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE +
5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the
kernel tear sockets down on flaky networks before the app sent its first
keepalive byte — drops at ~10s during long thinking phases. New named
constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the
inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't
re-introduce the misalignment.
Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger.
Without it, any narrow race between the streaming thread clearing
pending_user_message and STREAMS.pop(stream_id) produces a false-positive
'Previous turn did not complete.' marker on a turn that finished correctly
(reproducible after every command-approval turn). Defense-in-depth, not
the root-cause fix — the actual streaming-thread leak path is tracked
separately. Falsy pending_started_at (legacy sidecars) treated as
'old enough' so legitimate legacy-data recovery still works. Plus
logger.warning telemetry on every legitimate repair so the next batch of
user reports tells us whether the underlying race still fires.
Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM,
TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style
model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New
_LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/
RFC1918 heuristic — either signal triggers no-strip. Backward compat
preserved for OpenAI-compatible proxies on public hosts (LiteLLM at
litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the
existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom
rule for loopback hosts (see api/config.py and test_model_resolver.py
docstring update). Reported by @akarichan8231 in Discord on 2026-05-04.
42 regression tests across:
tests/test_issue1623_sse_heartbeat_alignment.py (3)
tests/test_issue1624_repair_stale_pending_grace.py (9)
tests/test_issue1625_local_server_model_id_preservation.py (30)
4142 -> 4184 passing. 0 regressions.
2026-05-04 16:31:32 +00:00
# Local model servers (LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI)
# register models under their full HuggingFace-style id. Stripping the
# prefix breaks the lookup and causes a fresh instance to load with
# default settings, ignoring user-tuned context length / parallel slots.
# See #1625. Detect either by canonical provider name OR by base_url
# pointing at a loopback/private host.
2026-05-08 00:41:55 +00:00
if ( _is_local_server_provider ( config_provider )
fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625)
Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived
handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller).
Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE +
5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the
kernel tear sockets down on flaky networks before the app sent its first
keepalive byte — drops at ~10s during long thinking phases. New named
constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the
inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't
re-introduce the misalignment.
Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger.
Without it, any narrow race between the streaming thread clearing
pending_user_message and STREAMS.pop(stream_id) produces a false-positive
'Previous turn did not complete.' marker on a turn that finished correctly
(reproducible after every command-approval turn). Defense-in-depth, not
the root-cause fix — the actual streaming-thread leak path is tracked
separately. Falsy pending_started_at (legacy sidecars) treated as
'old enough' so legitimate legacy-data recovery still works. Plus
logger.warning telemetry on every legitimate repair so the next batch of
user reports tells us whether the underlying race still fires.
Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM,
TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style
model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New
_LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/
RFC1918 heuristic — either signal triggers no-strip. Backward compat
preserved for OpenAI-compatible proxies on public hosts (LiteLLM at
litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the
existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom
rule for loopback hosts (see api/config.py and test_model_resolver.py
docstring update). Reported by @akarichan8231 in Discord on 2026-05-04.
42 regression tests across:
tests/test_issue1623_sse_heartbeat_alignment.py (3)
tests/test_issue1624_repair_stale_pending_grace.py (9)
tests/test_issue1625_local_server_model_id_preservation.py (30)
4142 -> 4184 passing. 0 regressions.
2026-05-04 16:31:32 +00:00
or _base_url_points_at_local_server ( config_base_url ) ) :
return model_id , config_provider , config_base_url
2026-06-10 08:56:01 +00:00
# Strip the provider prefix only when it's a known provider namespace
# AND stripping is the right call for this configured provider:
#
# * A real first-party provider pointed at an OpenAI-compatible proxy
# (e.g. provider=openai + base_url=litellm) expects the bare id —
# "openai/gpt-5.4" → "gpt-5.4", "google/gemma-…" → "gemma-…". This
# is the #433 behaviour and applies whenever config_provider is not
# the bare "custom" pseudo-provider.
#
# * A *bare* ``custom`` provider (or a named ``custom:<slug>``) is a
# vendor-routing proxy (LiteLLM, Bedrock gateway, OpenRouter-style
# multi-vendor endpoint). There we strip ONLY a prefix that is
# redundant with the model's own first-party namespace
# ("openai/gpt-5.4" → gpt-5.4, since gpt-5.4 is genuinely an OpenAI
# model — #433). An intrinsic routing prefix whose bare id is NOT a
# first-party model of that namespace is kept whole, because the
# proxy routes on the full string and truncating it 403s "model not
# allowed": "bedrock/opus-4-6" stays intact (opus-4-6 ∉ bedrock
# catalog — #3872).
#
# Unknown prefixes (e.g. "zai-org/GLM-5.1" on DeepInfra) are intrinsic
# to the model ID and always preserved (#548). The redundant-prefix
# strip that matches the *configured* provider's own family is handled
# earlier by the ``prefix == config_provider`` branch.
_cp_lower = ( config_provider or " " ) . strip ( ) . lower ( )
_is_custom = _cp_lower == " custom " or _cp_lower . startswith ( " custom: " )
if prefix in _PROVIDER_MODELS and (
not _is_custom or _is_first_party_model ( prefix , bare )
) :
2026-04-15 22:11:15 +00:00
return bare , config_provider , config_base_url
2026-06-10 08:56:01 +00:00
# Intrinsic / unknown prefix — pass the full model_id through unchanged.
2026-04-15 22:11:15 +00:00
return model_id , config_provider , config_base_url
2026-04-22 23:05:27 +00:00
fix: cross-provider model pick causes Connection lost on non-OpenRouter profiles
Root cause: resolve_model_provider() had a branch:
if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
return bare, prefix, None
When Camanji profile (config_provider='anthropic') picked openai/gpt-5.4-mini
from the OpenRouter dropdown, prefix='openai' matched _PROVIDER_MODELS and
config_provider was not 'openrouter', so it returned ('gpt-5.4-mini', 'openai', None).
The agent then demanded OPENAI_API_KEY directly -- not found -- RuntimeError --
stream crashed -- 'Connection lost'.
Fix: if prefix != config_provider (cross-provider selection), always route through
openrouter with the full provider/model string. Only strip the prefix and call a
direct provider API when the config_provider EXACTLY matches the model prefix.
Cases verified:
openrouter + openai/gpt-5.4-mini -> (openai/gpt-5.4-mini, openrouter) ✓
anthropic + openai/gpt-5.4-mini -> (openai/gpt-5.4-mini, openrouter) ✓ FIXED
anthropic + anthropic/claude-... -> (claude-..., anthropic) ✓
anthropic + claude-sonnet-4-6 bare -> (claude-sonnet-4-6, anthropic) ✓
openrouter + anthropic/claude-... -> (anthropic/claude-..., openrouter) ✓
Tests: 426 passed, 0 failed.
2026-04-03 20:23:25 +00:00
# If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
# In this case always route through openrouter with the full provider/model string.
2026-06-15 02:56:12 +00:00
# Exception (#4210): a custom provider (bare ``custom`` or named ``custom:<slug>``)
# is a vendor-routing proxy, not a first-party provider — its model ids commonly
# contain a known-provider prefix that the proxy uses for upstream routing, not
# an OpenRouter dropdown selection. Keep the request on the custom provider; the
# base_url-set sibling of this exception lives earlier in the ``config_base_url``
# branch (#3872).
_cp_lower_cross = ( config_provider or " " ) . strip ( ) . lower ( )
_is_custom_cross = _cp_lower_cross == " custom " or _cp_lower_cross . startswith ( " custom: " )
if prefix in _PROVIDER_MODELS and prefix != config_provider and not _is_custom_cross :
2026-04-12 07:11:41 +00:00
return model_id , " openrouter " , None
2026-04-02 05:56:34 +00:00
2026-04-02 06:15:04 +00:00
return model_id , config_provider , config_base_url
2026-04-02 05:56:34 +00:00
2026-05-07 12:25:04 +00:00
def resolve_custom_provider_connection ( provider_id : str ) - > tuple [ str | None , str | None ] :
""" Return (api_key, base_url) for a named ``custom:*`` provider.
Supports ` ` custom_providers [ ] . api_key ` ` as either a literal key or
` ` $ { ENV_VAR } ` ` , and ` ` custom_providers [ ] . key_env ` ` as an env - var hint .
Returns ` ` ( None , None ) ` ` when no named custom provider matches .
"""
pid = str ( provider_id or " " ) . strip ( ) . lower ( )
if not pid . startswith ( " custom: " ) :
return None , None
def _slugify ( value : str ) - > str :
s = str ( value or " " ) . strip ( ) . lower ( ) . replace ( " _ " , " - " ) . replace ( " " , " - " )
while " -- " in s :
s = s . replace ( " -- " , " - " )
return s . strip ( " - " )
slug = _slugify ( pid . split ( " : " , 1 ) [ 1 ] . strip ( ) )
if not slug :
return None , None
# Read the live config snapshot to avoid stale module-level cache edge
# cases after profile switches or runtime config edits.
cfg_data = get_config ( )
2026-05-18 22:17:45 +00:00
def _resolve_key ( raw_api_key , raw_key_env , provider_hint = None ) - > str | None :
2026-05-07 12:25:04 +00:00
api_key = None
if raw_api_key is not None :
key_text = str ( raw_api_key ) . strip ( )
if key_text . startswith ( " $ { " ) and key_text . endswith ( " } " ) and len ( key_text ) > 3 :
2026-06-20 06:24:07 +00:00
api_key = _thread_local_env_value ( key_text [ 2 : - 1 ] ) . strip ( ) or None
2026-05-07 12:25:04 +00:00
elif key_text :
api_key = key_text
if not api_key :
key_env = str ( raw_key_env or " " ) . strip ( )
if key_env :
2026-06-20 06:24:07 +00:00
api_key = _thread_local_env_value ( key_env ) . strip ( ) or None
2026-05-18 22:17:45 +00:00
if not api_key and provider_hint :
api_key = _lookup_custom_api_key_env ( provider_hint )
2026-05-07 12:25:04 +00:00
return api_key
custom_providers = cfg_data . get ( " custom_providers " , [ ] )
if not isinstance ( custom_providers , list ) :
custom_providers = [ ]
for entry in custom_providers :
if not isinstance ( entry , dict ) :
continue
name = str ( entry . get ( " name " ) or " " ) . strip ( )
if not name :
continue
entry_slug = _slugify ( name )
if entry_slug != slug :
continue
base_url = str ( entry . get ( " base_url " ) or " " ) . strip ( ) or None
2026-05-18 22:17:45 +00:00
api_key = _resolve_key ( entry . get ( " api_key " ) , entry . get ( " key_env " ) , pid )
2026-05-07 12:25:04 +00:00
return api_key , base_url
# If exactly one custom provider is configured, use it as a pragmatic
# fallback for mismatched slugs (e.g. punctuation differences).
if len ( custom_providers ) == 1 and isinstance ( custom_providers [ 0 ] , dict ) :
entry = custom_providers [ 0 ]
return (
2026-05-18 22:17:45 +00:00
_resolve_key ( entry . get ( " api_key " ) , entry . get ( " key_env " ) , pid ) ,
2026-05-07 12:25:04 +00:00
str ( entry . get ( " base_url " ) or " " ) . strip ( ) or None ,
)
# Fallbacks for setups that don't use custom_providers names directly.
providers_cfg = cfg_data . get ( " providers " , { } )
provider_specific = providers_cfg . get ( pid , { } ) if isinstance ( providers_cfg , dict ) else { }
provider_custom = providers_cfg . get ( " custom " , { } ) if isinstance ( providers_cfg , dict ) else { }
model_cfg = cfg_data . get ( " model " , { } )
model_provider = str ( model_cfg . get ( " provider " ) or " " ) . strip ( ) . lower ( ) if isinstance ( model_cfg , dict ) else " "
fallback_base = None
for candidate in ( provider_specific , provider_custom , model_cfg ) :
if isinstance ( candidate , dict ) :
_base = str ( candidate . get ( " base_url " ) or " " ) . strip ( )
if _base :
fallback_base = _base
break
fallback_key = None
if isinstance ( provider_specific , dict ) :
2026-05-18 22:17:45 +00:00
fallback_key = _resolve_key ( provider_specific . get ( " api_key " ) , provider_specific . get ( " key_env " ) , pid )
2026-05-07 12:25:04 +00:00
if not fallback_key and isinstance ( provider_custom , dict ) :
2026-05-18 22:17:45 +00:00
fallback_key = _resolve_key ( provider_custom . get ( " api_key " ) , provider_custom . get ( " key_env " ) , pid )
2026-05-07 12:25:04 +00:00
if not fallback_key and isinstance ( model_cfg , dict ) and model_provider in { " custom " , pid , slug } :
2026-05-18 22:17:45 +00:00
fallback_key = _resolve_key ( model_cfg . get ( " api_key " ) , model_cfg . get ( " key_env " ) , pid )
2026-05-07 12:25:04 +00:00
if fallback_key or fallback_base :
return fallback_key , fallback_base or None
return None , None
2026-05-23 09:59:03 +00:00
# Subprocess ACP transports (Cursor/Copilot CLI). Model IDs often contain '/'
# but must still route via explicit @provider:model so they do not fall through
# to the configured default HTTP provider (e.g. openai-codex).
_ACP_SUBPROCESS_PROVIDERS = frozenset ( { " cursor-acp " , " copilot-acp " } )
2026-05-01 05:23:47 +00:00
def model_with_provider_context ( model_id : str , model_provider : str | None = None ) - > str :
""" Return the model string to pass to ``resolve_model_provider()``.
Session persistence keeps the user ' s selected provider in ``model_provider``
instead of forcing every selected model into ` ` @provider : model ` ` form . At
runtime , however , ` ` resolve_model_provider ( ) ` ` still understands that
internal disambiguation form , so use it only when the provider context is
needed to route away from the current default provider .
"""
model = str ( model_id or " " ) . strip ( )
provider = str ( model_provider or " " ) . strip ( ) . lower ( )
if not model or not provider or provider == " default " or model . startswith ( " @ " ) :
return model
model_cfg = cfg . get ( " model " , { } )
config_provider = None
if isinstance ( model_cfg , dict ) :
config_provider = str ( model_cfg . get ( " provider " ) or " " ) . strip ( ) . lower ( )
2026-05-23 09:59:03 +00:00
# ACP subprocess providers always need the explicit hint — their slash IDs
# are not OpenRouter paths and must not inherit config_provider routing.
if provider in _ACP_SUBPROCESS_PROVIDERS :
return f " @ { provider } : { model } "
2026-05-01 05:23:47 +00:00
# If the selected provider is already the configured provider, leaving the
# model bare preserves provider-specific base_url/proxy settings.
if provider == config_provider :
return model
# OpenRouter selections with slash IDs are explicit provider/model paths.
if provider == " openrouter " :
return f " @ { provider } : { model } "
2026-06-20 09:00:21 +00:00
# Explicit providers configured in config.yaml (for example local llama.cpp,
# Ollama, LM Studio, vLLM, or other OpenAI-compatible endpoints) must keep
# their provider hint even when the model ID is HuggingFace-style and
# contains '/'. Otherwise a selected local model such as
# 'unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL' inherits the default provider
# (e.g. openai-codex) and is sent to the wrong backend.
providers_cfg = cfg . get ( " providers " ) if isinstance ( cfg , dict ) else { }
if isinstance ( providers_cfg , dict ) and provider in providers_cfg :
return f " @ { provider } : { model } "
# For non-OpenRouter slash IDs without an explicit configured provider,
# keep the ID intact so existing custom/proxy base_url routing and
# portal-provider handling remain in charge.
2026-05-01 05:23:47 +00:00
if " / " in model :
return model
return f " @ { provider } : { model } "
2026-04-20 22:12:01 +00:00
def get_effective_default_model ( config_data : dict | None = None ) - > str :
""" Resolve the effective Hermes default model from config, then env overrides. """
active_cfg = config_data if config_data is not None else cfg
default_model = DEFAULT_MODEL
model_cfg = active_cfg . get ( " model " , { } )
if isinstance ( model_cfg , str ) :
default_model = model_cfg . strip ( )
elif isinstance ( model_cfg , dict ) :
cfg_default = str ( model_cfg . get ( " default " ) or " " ) . strip ( )
if cfg_default :
default_model = cfg_default
env_model = (
os . getenv ( " HERMES_MODEL " ) or os . getenv ( " OPENAI_MODEL " ) or os . getenv ( " LLM_MODEL " )
)
if env_model :
default_model = env_model . strip ( )
return default_model
2026-04-21 22:26:52 +00:00
# ── Reasoning config (CLI parity for /reasoning) ─────────────────────────────
# Mirrors hermes_constants.parse_reasoning_effort so WebUI can validate without
# importing from the agent tree (which may not be installed). Any drift here
# will show up in the shared test suite since both sides accept the same set.
2026-06-13 17:59:55 +00:00
# Keep this WebUI-visible set aligned with hermes-agent#29248.
VALID_REASONING_EFFORTS = ( " minimal " , " low " , " medium " , " high " , " xhigh " )
2026-04-21 22:26:52 +00:00
def parse_reasoning_effort ( effort ) :
""" Parse an effort level into the dict the agent expects.
Returns None when * effort * is empty or unrecognised ( caller interprets as
" use default " ) , ` ` { " enabled " : False } ` ` for ` ` " none " ` ` , and
` ` { " enabled " : True , " effort " : < level > } ` ` for any of
` ` VALID_REASONING_EFFORTS ` ` .
"""
if not effort or not str ( effort ) . strip ( ) :
return None
eff = str ( effort ) . strip ( ) . lower ( )
if eff == " none " :
return { " enabled " : False }
if eff in VALID_REASONING_EFFORTS :
return { " enabled " : True , " effort " : eff }
return None
2026-07-01 14:35:14 +00:00
def _strip_provider_hint_for_reasoning ( model_id : str , provider : str | None = None ) - > str :
""" Remove WebUI routing hints before provider-specific capability lookup.
A plain ` ` @provider : model ` ` hint strips cleanly on the first colon . But a
* named * custom provider hint is ` ` @custom : < slug > : model ` ` — two colons —
and the naive first - colon split only strips the leading ` ` @custom : ` ` ,
leaving ` ` < slug > : model ` ` behind . That leftover slug fragment can hide a
nested gateway route from prefix - based checks like
` ` _nested_route_reasoning_denied ( ) ` ` ( e . g . ` ` agg : vertex / gemini - image - 1.0 ` `
no longer starts with ` ` vertex / gemini - ` ` ) , silently re - enabling reasoning
controls on routes that must never expose them .
When the resolved * provider * is known ( e . g . ` ` " custom:agg " ` ` ) , strip the
exact ` ` @ { provider } : ` ` prefix first so both segments are removed in one
pass . Falls back to the generic first - colon split when no provider is
given or it doesn ' t match — preserving prior behavior for plain
` ` @provider : model ` ` hints .
"""
2026-05-23 10:37:46 +00:00
model = str ( model_id or " " ) . strip ( )
2026-07-01 14:35:14 +00:00
if not model . startswith ( " @ " ) :
return model
if provider :
exact_prefix = f " @ { provider } : " . lower ( )
if model . lower ( ) . startswith ( exact_prefix ) :
return model [ len ( exact_prefix ) : ]
if " : " in model :
2026-05-23 10:37:46 +00:00
return model . split ( " : " , 1 ) [ 1 ]
return model
2026-06-01 18:23:49 +00:00
def _reasoning_name_candidates ( model_id : str ) - > list [ str ] :
""" Return normalized model-name candidates for heuristic capability checks. """
bare = str ( model_id or " " ) . strip ( ) . lower ( ) . rsplit ( " / " , 1 ) [ - 1 ]
if not bare :
return [ ]
candidates : list [ str ] = [ ]
def _add ( value : str ) - > None :
candidate = str ( value or " " ) . strip ( ) . lower ( )
if candidate and candidate not in candidates :
candidates . append ( candidate )
_add ( bare )
dot_parts = [ part for part in bare . split ( " . " ) if part ]
if len ( dot_parts ) > 1 :
# Try progressively stripping dot-separated vendor namespaces so inputs like
# "moonshotai.kimi-k2.5" and "vendor.deepseek.v3.2" both surface the real
# model family rather than treating every dot as part of the provider slug.
for index in range ( 1 , len ( dot_parts ) ) :
suffix = " . " . join ( dot_parts [ index : ] )
if any ( ch . isalpha ( ) for ch in suffix ) :
_add ( suffix )
for candidate in list ( candidates ) :
normalized = re . sub ( r " [^a-z0-9]+ " , " - " , candidate ) . strip ( " - " )
_add ( normalized )
return candidates
def _candidate_supports_reasoning ( candidate : str ) - > bool :
normalized = re . sub ( r " [^a-z0-9]+ " , " - " , str ( candidate or " " ) . strip ( ) . lower ( ) ) . strip ( " - " )
if not normalized :
return False
tokens = [ token for token in normalized . split ( " - " ) if token ]
token_set = set ( tokens )
if " thinking " in token_set or " reasoning " in token_set :
return True
2026-06-02 04:34:40 +00:00
if " gpt " in token_set or normalized . startswith ( " gpt " ) :
# Restrict to GPT-5+ (exclude GPT-4o/4.1/3.5 — reasoning_effort unsupported)
m = re . search ( r " gpt-( \ d+) " , normalized )
if m and int ( m . group ( 1 ) ) > = 5 :
return True
return False
2026-06-01 18:23:49 +00:00
if normalized in { " o1 " , " o3 " , " o4 " } or normalized . startswith ( ( " o1- " , " o3- " , " o4- " ) ) :
return True
2026-06-02 04:34:40 +00:00
if " claude " in token_set or normalized . startswith ( " claude " ) :
# Restrict to Claude 4+ or Claude 3.7+ (exclude Claude 3.0/3.5)
match = re . search ( r " claude.*?( \ d+)(?: \ D+( \ d+))? " , normalized )
if match :
major = int ( match . group ( 1 ) )
minor = int ( match . group ( 2 ) ) if match . group ( 2 ) else 0
if major > = 4 or ( major == 3 and minor > = 7 ) :
return True
return False
if " qwen " in token_set or normalized . startswith ( " qwen " ) :
# Restrict to Qwen 3+ (exclude Qwen 2/2.5)
match = re . search ( r " qwen.*?( \ d+)(?: \ D+( \ d+))? " , normalized )
if match :
major = int ( match . group ( 1 ) )
if major > = 3 :
return True
return False
if " kimi " in token_set or normalized . startswith ( " kimi " ) :
return True
if " minimax " in token_set or normalized . startswith ( " minimax " ) :
return True
if " mimo " in token_set or normalized . startswith ( " mimo " ) :
return True
if " glm " in token_set or normalized . startswith ( " glm " ) :
2026-06-01 18:23:49 +00:00
return True
2026-06-02 04:34:40 +00:00
if " step " in token_set or normalized . startswith ( " step " ) :
2026-06-01 18:23:49 +00:00
return True
2026-06-05 17:44:02 +00:00
if " deepseek " in token_set :
# Check the token immediately after "deepseek" for a V-series or R-series
# version marker (e.g. "v4", "r1"). This is position-independent so it
# handles bare "deepseek-v4-flash" and @custom:name:DeepSeek-V4-Flash
# (→ "my-provider-deepseek-v4-flash") equally, while correctly excluding
# non-reasoning models like deepseek-chat and deepseek-coder.
# Using tokens.index() ensures the provider slug (e.g. "vertex" in
# "vertex-deepseek-chat") cannot falsely trigger the version guard.
idx = tokens . index ( " deepseek " )
if idx + 1 < len ( tokens ) and tokens [ idx + 1 ] . startswith ( ( " v " , " r " ) ) :
return True
2026-06-01 18:23:49 +00:00
return False
refactor: make nested-route reasoning deny boundary-based, not prefix-based
Structural hardening per user request, following the 3rd round of the
same bypass class on PR #5313's reasoning_efforts feature:
Round 1: plain-ordering regression (provider-config short-circuit ran
before the nested-route deny).
Round 2: a provider-qualified hint (@custom:<slug>:vertex/gemini-...)
left a slug fragment that the deny's prefix-match missed.
Both were legitimate, narrowly-targeted fixes, but the pattern —
_nested_route_reasoning_denied() requiring the model string to START
WITH the route prefix — meant every future wrapper/nesting scheme
would need the strip logic to be updated in lockstep, and a missed
case fails OPEN (reasoning re-enabled on a route that must never show
it), which is the wrong failure mode for a security-adjacent guard.
This commit removes that class of bug instead of patching its latest
instance: _nested_route_reasoning_denied() now searches for the
vertex/gemini- or gemini_cli/gemini- pattern ANYWHERE in the string at
a non-alphanumeric boundary, rather than requiring it at position 0.
Correctness no longer depends on _strip_provider_hint_for_reasoning()
having stripped exactly the right prefix first — any number of opaque
wrapper layers (@provider:, a named custom-provider slug, or any
nesting scheme not yet invented) can precede the route and the deny
still fires.
Verified: all historical bypass strings (round 1 and round 2, plus a
hypothetical deeper double-wrapped case) now correctly deny; embedded
substrings that must NOT match (e.g. 'notvertex/gemini-x') correctly
don't, thanks to the boundary lookbehind.
Added test_nested_route_deny_is_boundary_based_not_prefix_based
locking in the structural invariant directly, independent of any
particular wrapper scheme.
Verification:
./scripts/test.sh tests/test_reasoning_effort_model_capabilities.py tests/test_custom_provider_bare_model_reasoning.py
# 54 passed in 1.31s
Full suite: 11257 passed, 64 pre-existing failures (identical file/test
set as the prior commit's baseline — network/credential/profile-isolation/fd-leak
tests, unrelated to reasoning_efforts).
2026-07-01 15:02:45 +00:00
# Matches the nested Gemini gateway route prefix anywhere it appears in a
# model id, as long as it isn't embedded inside a larger alphanumeric token
# (the negative lookbehind excludes false positives like "notvertex/gemini-x").
# Scanning for the pattern at any position — rather than requiring the whole
# string to start with it — makes the check independent of how many wrapper
# layers precede the route: ``@provider:``, a named custom-provider slug
# (``@custom:<slug>:``), or any future nesting scheme none of us have
# invented yet. This invariant (Gemini image/embedding routes must never
# expose a reasoning toggle) was bypassed twice via different edge cases in
# the prefix-stripping logic before being made structurally boundary-based
# instead of prefix-based — see PR #5313 review history.
_NESTED_ROUTE_PATTERN = re . compile ( r " (?<![a-z0-9])(vertex/gemini-|gemini_cli/gemini-)(.*)$ " )
2026-06-14 04:44:47 +00:00
def _nested_route_reasoning_denied ( model : str ) - > bool :
refactor: make nested-route reasoning deny boundary-based, not prefix-based
Structural hardening per user request, following the 3rd round of the
same bypass class on PR #5313's reasoning_efforts feature:
Round 1: plain-ordering regression (provider-config short-circuit ran
before the nested-route deny).
Round 2: a provider-qualified hint (@custom:<slug>:vertex/gemini-...)
left a slug fragment that the deny's prefix-match missed.
Both were legitimate, narrowly-targeted fixes, but the pattern —
_nested_route_reasoning_denied() requiring the model string to START
WITH the route prefix — meant every future wrapper/nesting scheme
would need the strip logic to be updated in lockstep, and a missed
case fails OPEN (reasoning re-enabled on a route that must never show
it), which is the wrong failure mode for a security-adjacent guard.
This commit removes that class of bug instead of patching its latest
instance: _nested_route_reasoning_denied() now searches for the
vertex/gemini- or gemini_cli/gemini- pattern ANYWHERE in the string at
a non-alphanumeric boundary, rather than requiring it at position 0.
Correctness no longer depends on _strip_provider_hint_for_reasoning()
having stripped exactly the right prefix first — any number of opaque
wrapper layers (@provider:, a named custom-provider slug, or any
nesting scheme not yet invented) can precede the route and the deny
still fires.
Verified: all historical bypass strings (round 1 and round 2, plus a
hypothetical deeper double-wrapped case) now correctly deny; embedded
substrings that must NOT match (e.g. 'notvertex/gemini-x') correctly
don't, thanks to the boundary lookbehind.
Added test_nested_route_deny_is_boundary_based_not_prefix_based
locking in the structural invariant directly, independent of any
particular wrapper scheme.
Verification:
./scripts/test.sh tests/test_reasoning_effort_model_capabilities.py tests/test_custom_provider_bare_model_reasoning.py
# 54 passed in 1.31s
Full suite: 11257 passed, 64 pre-existing failures (identical file/test
set as the prior commit's baseline — network/credential/profile-isolation/fd-leak
tests, unrelated to reasoning_efforts).
2026-07-01 15:02:45 +00:00
""" Hard deny for nested Gemini gateway routes that must never show a reasoning toggle.
Matches the route pattern anywhere in * model * , not just when the whole
string starts with it , so this check does not depend on a caller having
stripped exactly the right wrapper prefix first . Callers should still
pass the least - wrapped form they have ( e . g . after
` ` _strip_provider_hint_for_reasoning ` ` ) for clarity , but correctness no
longer hinges on it .
"""
2026-06-14 04:44:47 +00:00
lower = str ( model or " " ) . strip ( ) . lower ( )
if not lower :
return False
refactor: make nested-route reasoning deny boundary-based, not prefix-based
Structural hardening per user request, following the 3rd round of the
same bypass class on PR #5313's reasoning_efforts feature:
Round 1: plain-ordering regression (provider-config short-circuit ran
before the nested-route deny).
Round 2: a provider-qualified hint (@custom:<slug>:vertex/gemini-...)
left a slug fragment that the deny's prefix-match missed.
Both were legitimate, narrowly-targeted fixes, but the pattern —
_nested_route_reasoning_denied() requiring the model string to START
WITH the route prefix — meant every future wrapper/nesting scheme
would need the strip logic to be updated in lockstep, and a missed
case fails OPEN (reasoning re-enabled on a route that must never show
it), which is the wrong failure mode for a security-adjacent guard.
This commit removes that class of bug instead of patching its latest
instance: _nested_route_reasoning_denied() now searches for the
vertex/gemini- or gemini_cli/gemini- pattern ANYWHERE in the string at
a non-alphanumeric boundary, rather than requiring it at position 0.
Correctness no longer depends on _strip_provider_hint_for_reasoning()
having stripped exactly the right prefix first — any number of opaque
wrapper layers (@provider:, a named custom-provider slug, or any
nesting scheme not yet invented) can precede the route and the deny
still fires.
Verified: all historical bypass strings (round 1 and round 2, plus a
hypothetical deeper double-wrapped case) now correctly deny; embedded
substrings that must NOT match (e.g. 'notvertex/gemini-x') correctly
don't, thanks to the boundary lookbehind.
Added test_nested_route_deny_is_boundary_based_not_prefix_based
locking in the structural invariant directly, independent of any
particular wrapper scheme.
Verification:
./scripts/test.sh tests/test_reasoning_effort_model_capabilities.py tests/test_custom_provider_bare_model_reasoning.py
# 54 passed in 1.31s
Full suite: 11257 passed, 64 pre-existing failures (identical file/test
set as the prior commit's baseline — network/credential/profile-isolation/fd-leak
tests, unrelated to reasoning_efforts).
2026-07-01 15:02:45 +00:00
match = _NESTED_ROUTE_PATTERN . search ( lower )
if not match :
return False
tail = match . group ( 2 )
return tail . startswith ( " embedding " ) or " image " in tail or " imagine " in tail
2026-06-14 04:44:47 +00:00
def _nested_gateway_route_reasoning ( model : str ) - > bool :
""" Recognize nested ``vertex/gemini-`` and ``gemini_cli/gemini-`` routes on custom providers.
The slash - prefix heuristic list includes ` ` google / gemini - 2 ` ` but not gateway - prefixed
Gemini ids , so capable models behind custom aggregators stayed hidden .
"""
lower = str ( model or " " ) . strip ( ) . lower ( )
if not lower :
return False
for prefix in ( " vertex/gemini- " , " gemini_cli/gemini- " ) :
if lower . startswith ( prefix ) :
tail = lower [ len ( prefix ) : ]
if tail . startswith ( " embedding " ) or " image " in tail or " imagine " in tail :
return False
# Gemini thinking/reasoning controls are documented for the 2.5
# series and 3-era models only — 1.5 (and earlier) have no thinking
# support, so a reasoning selector on e.g. ``vertex/gemini-1.5-pro``
# would let a user pick an effort that the route then rejects.
# Version-gate the allow to the reasoning-capable families.
if (
tail == " 2.5 "
or tail . startswith ( ( " 2.5- " , " 2.5. " , " 3- " , " 3. " ) )
or " thinking " in tail
or " reasoning " in tail
) :
return True
return False
return False
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
def _filter_reasoning_efforts_for_provider (
efforts : list [ str ] ,
model_id : str ,
provider_id : str ,
) - > list [ str ] :
""" Apply provider/model quirks to otherwise valid reasoning effort levels. """
normalized = [
str ( eff ) . strip ( ) . lower ( )
for eff in efforts
if str ( eff ) . strip ( ) . lower ( ) in VALID_REASONING_EFFORTS
]
normalized = list ( dict . fromkeys ( normalized ) )
provider = _resolve_provider_alias ( str ( provider_id or " " ) . strip ( ) . lower ( ) )
bare = _strip_provider_hint_for_reasoning ( model_id ) . lower ( ) . rsplit ( " / " , 1 ) [ - 1 ]
if provider == " openai-codex " :
if bare . startswith ( ( " o1 " , " o3 " , " o4 " ) ) :
return [ eff for eff in normalized if eff in { " low " , " medium " , " high " } ]
if bare . startswith ( " gpt-5 " ) :
return [ eff for eff in normalized if eff != " max " ]
return normalized
2026-05-23 09:59:45 +00:00
def _heuristic_reasoning_efforts ( model_id : str , provider_id : str ) - > list [ str ] :
""" Fallback when hermes_cli is unavailable. """
2026-05-23 10:37:46 +00:00
model = _strip_provider_hint_for_reasoning ( model_id ) . lower ( )
2026-05-23 09:59:45 +00:00
provider = _resolve_provider_alias ( str ( provider_id or " " ) . strip ( ) . lower ( ) )
if not model or provider in { " cursor-acp " , " copilot-acp " } :
return [ ]
bare = model . rsplit ( " / " , 1 ) [ - 1 ]
if provider == " openai-codex " and bare . startswith ( ( " gpt-5 " , " o1 " , " o3 " , " o4 " ) ) :
if bare . startswith ( ( " o1 " , " o3 " , " o4 " ) ) :
return [ " low " , " medium " , " high " ]
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
return _filter_reasoning_efforts_for_provider (
list ( VALID_REASONING_EFFORTS ) , model , provider
)
2026-05-23 09:59:45 +00:00
if provider in { " copilot " , " github-copilot " } :
if bare . startswith ( ( " gpt-5 " , " o1 " , " o3 " , " o4 " ) ) :
if bare . startswith ( ( " o1 " , " o3 " , " o4 " ) ) :
return [ " low " , " medium " , " high " ]
return list ( VALID_REASONING_EFFORTS )
prefixes = (
" deepseek/ " ,
" anthropic/ " ,
" openai/ " ,
" x-ai/ " ,
" google/gemini-2 " ,
" google/gemma-4 " ,
" qwen/qwen3 " ,
" tencent/hy3-preview " ,
" xiaomi/ " ,
)
if any ( model . startswith ( prefix ) for prefix in prefixes ) :
return list ( VALID_REASONING_EFFORTS )
2026-06-14 04:44:47 +00:00
if _nested_gateway_route_reasoning ( model ) :
return list ( VALID_REASONING_EFFORTS )
2026-06-01 18:23:49 +00:00
# Named custom providers often rewrite model ids with dots, underscores, or
# extra vendor namespaces. Normalize those shapes before applying family-level
# reasoning heuristics so "deepseek.v3.2", "deepseek_v4_flash", and
# "vendor.deepseek.v3.2" are treated consistently.
if any ( _candidate_supports_reasoning ( candidate ) for candidate in _reasoning_name_candidates ( bare ) ) :
fix(reasoning): expose effort levels for bare/dot-separated model names on custom providers
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``.
2026-05-30 18:51:26 +00:00
return list ( VALID_REASONING_EFFORTS )
2026-05-23 09:59:45 +00:00
return [ ]
2026-05-27 09:54:45 +00:00
def _models_dev_reasoning_efforts ( model_id : str , provider_id : str ) - > list [ str ] | None :
""" Return reasoning efforts from Hermes Agent model metadata when known.
` ` None ` ` means the metadata source is unavailable or has no answer , so the
caller should continue to compatibility fallbacks . A concrete list ( including
` ` [ ] ` ` ) is authoritative .
"""
model = _strip_provider_hint_for_reasoning ( model_id )
provider = str ( provider_id or " " ) . strip ( ) . lower ( )
if not model or not provider :
return None
try :
from agent . models_dev import get_model_capabilities
except Exception :
return None
try :
capabilities = get_model_capabilities ( provider = provider , model = model )
except Exception :
return None
if capabilities is None :
return None
supports_reasoning = getattr ( capabilities , " supports_reasoning " , None )
if supports_reasoning is True :
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
return _filter_reasoning_efforts_for_provider (
list ( VALID_REASONING_EFFORTS ) , model , provider
)
2026-05-27 09:54:45 +00:00
if supports_reasoning is False :
return [ ]
return None
2026-06-14 22:46:35 +00:00
class _NoRedirectHandler ( urllib . request . HTTPRedirectHandler ) :
""" urllib redirect handler that refuses to follow any redirect.
Used by the LM Studio reasoning probe so a 3 xx from the probe URL can never
forward the ` ` Authorization ` ` header ( the configured LM Studio key ) to a
redirected , possibly attacker - controlled host . ` ` redirect_request ` `
returning ` ` None ` ` makes urllib raise the original 3 xx as an ` ` HTTPError ` ` ,
which the probe swallows . ( #3837 security review)
"""
def redirect_request ( self , req , fp , code , msg , headers , newurl ) :
return None
def _get_lmstudio_reasoning_probe_api_key ( ) - > str | None :
""" Resolve the LM Studio key for reasoning probes with WebUI precedence. """
config_data = cfg
model_cfg = config_data . get ( " model " ) or { }
if isinstance ( model_cfg , dict ) :
active_provider = str ( model_cfg . get ( " provider " ) or " " ) . strip ( ) . lower ( )
model_key = str ( model_cfg . get ( " api_key " ) or " " ) . strip ( )
if active_provider == " lmstudio " and model_key :
return model_key
providers_cfg = config_data . get ( " providers " ) or { }
if isinstance ( providers_cfg , dict ) :
lmstudio_cfg = providers_cfg . get ( " lmstudio " ) or { }
if isinstance ( lmstudio_cfg , dict ) :
config_key = str ( lmstudio_cfg . get ( " api_key " ) or " " ) . strip ( )
if config_key :
return config_key
env_key = str ( os . getenv ( " LM_API_KEY " ) or " " ) . strip ( )
if env_key :
return env_key
legacy_env_key = str ( os . getenv ( " LMSTUDIO_API_KEY " ) or " " ) . strip ( )
if legacy_env_key :
return legacy_env_key
return None
def _lmstudio_reasoning_probe_options_fallback (
model : str ,
base_url : str | None ,
* ,
api_key : str | None = None ,
timeout : float = 5.0 ,
) - > list [ str ] :
""" Query LM Studio reasoning options without relying on hermes_cli. """
server_root = str ( base_url or " " ) . strip ( ) . rstrip ( " / " )
if server_root . endswith ( " /v1 " ) :
server_root = server_root [ : - 3 ] . rstrip ( " / " )
if not server_root or not model :
return [ ]
headers = {
" Accept " : " application/json " ,
" User-Agent " : " hermes-webui-reasoning-probe " ,
}
if api_key :
headers [ " Authorization " ] = f " Bearer { api_key } "
request = urllib . request . Request (
server_root + " /api/v1/models " ,
headers = headers ,
method = " GET " ,
)
# SECURITY: never follow redirects on this probe. urllib re-sends request
# headers (including Authorization: Bearer <lmstudio key>) to the redirect
# target, so a 3xx from the probe URL could exfiltrate the configured
# LM Studio credential to an attacker-controlled host. A no-redirect opener
# turns any 3xx into an HTTPError we swallow below. (#3837 security review)
opener = urllib . request . build_opener ( _NoRedirectHandler )
try :
with opener . open ( request , timeout = timeout ) as response : # nosec B310
payload = json . loads ( response . read ( ) . decode ( " utf-8 " , errors = " replace " ) )
except urllib . error . HTTPError as exc :
logger . debug (
" LM Studio reasoning probe at %s failed with HTTP %s " ,
server_root ,
exc . code ,
)
return [ ]
except Exception as exc :
logger . debug ( " LM Studio reasoning probe at %s failed: %s " , server_root , exc )
return [ ]
raw_models = payload . get ( " models " ) if isinstance ( payload , dict ) else None
if not isinstance ( raw_models , list ) :
logger . debug (
" LM Studio reasoning probe at %s returned malformed payload " ,
server_root ,
)
return [ ]
for raw in raw_models :
if not isinstance ( raw , dict ) :
continue
if raw . get ( " key " ) != model and raw . get ( " id " ) != model :
continue
caps = raw . get ( " capabilities " )
reasoning = caps . get ( " reasoning " ) if isinstance ( caps , dict ) else None
opts = reasoning . get ( " allowed_options " ) if isinstance ( reasoning , dict ) else None
if isinstance ( opts , list ) :
return [ str ( opt ) . strip ( ) . lower ( ) for opt in opts if isinstance ( opt , str ) ]
return [ ]
return [ ]
def _lmstudio_model_reasoning_options (
model : str ,
base_url : str | None ,
* ,
api_key : str | None = None ,
timeout : float = 5.0 ,
) - > list [ str ] :
""" Prefer hermes_cli, but keep WebUI reasoning probes working without it.
SECURITY : when an ` ` api_key ` ` is being sent , always use the built - in
no - redirect fallback probe rather than ` ` hermes_cli ` ` . The bundled CLI probe
uses a plain ` ` urllib . request . urlopen ` ` that follows redirects and re - sends
the ` ` Authorization ` ` header to the redirect target , which could leak the
configured LM Studio credential to another host . We can only guarantee
redirect safety for code we control , so credentialed probes go through
` ` _lmstudio_reasoning_probe_options_fallback ` ` ( no - redirect opener ) . Keyless
probes have no credential to leak , so they may use the richer CLI path .
( #3837 security review)
"""
if api_key :
return _lmstudio_reasoning_probe_options_fallback (
model ,
base_url ,
api_key = api_key ,
timeout = timeout ,
)
try :
from hermes_cli . models import (
lmstudio_model_reasoning_options as _cli_lmstudio_model_reasoning_options ,
)
except Exception :
return _lmstudio_reasoning_probe_options_fallback (
model ,
base_url ,
api_key = api_key ,
timeout = timeout ,
)
try :
return _cli_lmstudio_model_reasoning_options (
model ,
base_url ,
api_key = api_key ,
timeout = timeout ,
)
except ( TypeError , AttributeError ) :
logger . warning (
" hermes_cli.lmstudio_model_reasoning_options has an unexpected signature; "
" falling back to the built-in LM Studio reasoning probe " ,
exc_info = True ,
)
return _lmstudio_reasoning_probe_options_fallback (
model ,
base_url ,
api_key = api_key ,
timeout = timeout ,
)
except Exception :
return _lmstudio_reasoning_probe_options_fallback (
model ,
base_url ,
api_key = api_key ,
timeout = timeout ,
)
2026-05-23 09:59:45 +00:00
def resolve_model_reasoning_efforts (
model_id : str | None = None ,
provider_id : str | None = None ,
base_url : str | None = None ,
) - > list [ str ] :
""" Return supported reasoning-effort levels for *model_id*, or [] if none. """
model = str ( model_id or " " ) . strip ( )
if not model :
return [ ]
provider = str ( provider_id or " " ) . strip ( ) . lower ( ) if provider_id else " "
resolved_base_url = str ( base_url or " " ) . strip ( ) or None
if not provider :
try :
_ , provider , resolved_base_url = resolve_model_provider ( model )
except Exception :
provider = str ( ( cfg . get ( " model " ) or { } ) . get ( " provider " ) or " " ) . strip ( ) . lower ( )
provider = _resolve_provider_alias ( provider )
2026-07-01 04:05:29 +00:00
2026-07-01 04:27:56 +00:00
# IDE-copilot providers never expose reasoning effort options.
# Guard early so a stray config entry can't override this.
2026-05-23 09:59:45 +00:00
if provider in { " cursor-acp " , " copilot-acp " } :
return [ ]
2026-07-01 14:35:14 +00:00
hinted_model = _strip_provider_hint_for_reasoning ( model , provider )
2026-05-27 09:54:45 +00:00
2026-07-01 12:35:08 +00:00
# Master hides reasoning controls for nested image/embedding routes. Keep
# that hard deny above provider config so an explicit allowlist cannot
# re-enable controls for routes that should never expose them.
2026-06-14 04:44:47 +00:00
if _nested_route_reasoning_denied ( hinted_model ) :
return [ ]
2026-07-01 12:35:08 +00:00
# 0. Provider config: providers.<name>.reasoning_efforts or named
# custom_providers[].reasoning_efforts. When the user has explicitly listed
# valid efforts for a provider, return that list directly — no heuristics,
# no models.dev lookup.
# Only short-circuits when the filtered list is non-empty; an all-invalid
# list (e.g. typos) falls through to heuristics instead of hiding reasoning.
_re_list = None
try :
if provider and provider . startswith ( " custom: " ) :
for _entry in _custom_provider_entries ( ) :
if _custom_provider_slug_from_name ( _entry . get ( " name " ) ) == provider :
_re_list = _entry . get ( " reasoning_efforts " )
break
elif provider :
_prov_entry = ( cfg . get ( " providers " ) or { } ) . get ( provider , { } )
if isinstance ( _prov_entry , dict ) :
_re_list = _prov_entry . get ( " reasoning_efforts " )
if isinstance ( _re_list , list ) and _re_list :
_filtered = [ str ( x ) . strip ( ) . lower ( ) for x in _re_list
if str ( x ) . strip ( ) . lower ( ) in { * VALID_REASONING_EFFORTS , " none " } ]
_filtered = list ( dict . fromkeys ( _filtered ) )
if _filtered :
return _filtered
except Exception :
pass
2026-06-14 22:46:35 +00:00
if provider in { " copilot " , " github-copilot " } :
try :
from hermes_cli . models import github_model_reasoning_efforts
except Exception :
2026-05-27 09:54:45 +00:00
return _heuristic_reasoning_efforts ( hinted_model , provider )
2026-06-14 22:46:35 +00:00
return _filter_reasoning_efforts_for_provider (
github_model_reasoning_efforts ( hinted_model ) , hinted_model , provider
)
if provider == " lmstudio " :
configured_base = _get_provider_base_url ( provider )
probe_base = resolved_base_url or configured_base
# SECURITY: only forward the configured LM Studio credential when the
# probe target is the configured LM Studio endpoint. /api/reasoning
# accepts a caller-supplied base_url, so a request could otherwise point
# the probe at an arbitrary host and harvest the stored key. When the
# caller supplies a base_url that does not normalize to the configured
# one, probe it WITHOUT a key. (#3837 security review)
probe_key : str | None = None
if not resolved_base_url or (
configured_base
and _normalize_base_url_for_match ( probe_base )
== _normalize_base_url_for_match ( configured_base )
) :
probe_key = _get_lmstudio_reasoning_probe_api_key ( )
opts = _lmstudio_model_reasoning_options (
hinted_model ,
probe_base ,
api_key = probe_key ,
)
normalized = [ str ( opt ) . strip ( ) . lower ( ) for opt in opts if str ( opt ) . strip ( ) ]
if not normalized or set ( normalized ) . issubset ( { " off " } ) :
return [ ]
level_opts = [ opt for opt in normalized if opt in VALID_REASONING_EFFORTS ]
if level_opts :
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
return _filter_reasoning_efforts_for_provider (
2026-06-14 22:46:35 +00:00
level_opts , hinted_model , provider
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
)
2026-06-14 22:46:35 +00:00
if set ( normalized ) . issubset ( { " off " , " on " } ) :
2026-05-23 09:59:45 +00:00
return [ ]
2026-06-14 22:46:35 +00:00
return [ ]
2026-05-23 09:59:45 +00:00
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
# _models_dev_reasoning_efforts already applies the provider/model filter
# internally, so it is returned as-is here (filtering again would be
# redundant — the filter is idempotent but the double pass obscures flow).
2026-05-27 09:54:45 +00:00
metadata_efforts = _models_dev_reasoning_efforts ( hinted_model , provider )
if metadata_efforts is not None :
return metadata_efforts
2026-05-23 09:59:45 +00:00
2026-05-27 09:54:45 +00:00
return _heuristic_reasoning_efforts ( hinted_model , provider )
2026-05-23 09:59:45 +00:00
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
def coerce_reasoning_effort_for_model (
effort : str | None ,
model_id : str | None = None ,
provider_id : str | None = None ,
base_url : str | None = None ,
) - > str :
""" Return the closest supported effort for the target model/provider. """
raw = str ( effort or " " ) . strip ( ) . lower ( )
if not raw :
return " "
if raw == " none " :
return " none "
2026-06-13 17:59:55 +00:00
accepts_max_as_xhigh = raw == " max "
if accepts_max_as_xhigh :
raw = " xhigh "
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
if raw not in VALID_REASONING_EFFORTS :
return " "
supported = resolve_model_reasoning_efforts (
model_id ,
provider_id = provider_id ,
base_url = base_url ,
)
# An empty list is ambiguous: resolve_model_reasoning_efforts() returns []
# both for models KNOWN not to support reasoning AND for models we simply
# don't recognize (custom providers, aggregator-rewritten ids, brand-new
# releases). Coercion exists to avoid sending a level a KNOWN-incompatible
2026-06-13 18:26:48 +00:00
# model rejects (e.g. openai-codex gpt-5 'max', o1/o3/o4 above 'high') -
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
# those paths return a NON-empty clamped set, so the degrade ladder below
# still applies. When the set is empty we can't tell "unsupported" from
2026-06-13 18:26:48 +00:00
# "unknown", so preserve the user's configured effort verbatim where it is
# still valid. A stale 'max' value is no longer parser-valid on the WebUI
# side, so degrade that unknown-model case to xhigh instead of silently
# dropping reasoning later in parse_reasoning_effort(). (#3505 review)
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
if not supported :
2026-06-13 18:26:48 +00:00
return " xhigh " if accepts_max_as_xhigh else raw
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
if raw in supported :
2026-06-13 17:59:55 +00:00
return " xhigh " if accepts_max_as_xhigh else raw
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
# Degrade to the closest *lower* supported level instead of silently
# disabling reasoning. e.g. max -> xhigh -> high, or xhigh -> high when the
# target model caps below the configured effort. Never escalate.
2026-06-13 18:26:48 +00:00
ladder = list ( VALID_REASONING_EFFORTS ) # ascending: minimal..xhigh
Release v0.51.247 — Release HO (stage-q19) (#3521)
## 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>
2026-06-04 02:21:26 +00:00
try :
raw_idx = ladder . index ( raw )
except ValueError :
return raw
for level in reversed ( ladder [ : raw_idx ] ) : # strictly lower, highest first
if level in supported :
return level
# raw is below every supported level (shouldn't happen for a non-empty set
# that excludes raw, but be safe): preserve the configured effort rather
# than blank it.
return raw
2026-05-23 09:59:45 +00:00
def get_reasoning_status (
* ,
model_id : str | None = None ,
provider_id : str | None = None ,
base_url : str | None = None ,
) - > dict :
2026-04-21 22:26:52 +00:00
""" Return current reasoning configuration from the active profile ' s
config . yaml — the same source of truth the CLI reads from .
Keys :
- show_reasoning : bool — from ` ` display . show_reasoning ` ` ( default True )
- reasoning_effort : str — from ` ` agent . reasoning_effort ` ` ( ' ' = default )
"""
config_data = _load_yaml_config_file ( _get_config_path ( ) )
display_cfg = config_data . get ( " display " ) or { }
agent_cfg = config_data . get ( " agent " ) or { }
show_raw = display_cfg . get ( " show_reasoning " ) if isinstance ( display_cfg , dict ) else None
effort_raw = agent_cfg . get ( " reasoning_effort " ) if isinstance ( agent_cfg , dict ) else None
2026-05-27 09:54:45 +00:00
resolve_model = model_id
resolve_provider = provider_id
resolve_base_url = base_url
if not resolve_model :
model_cfg = config_data . get ( " model " ) or { }
if isinstance ( model_cfg , dict ) :
resolve_model = str ( model_cfg . get ( " default " ) or " " ) . strip ( ) or None
if not resolve_provider and model_cfg . get ( " provider " ) :
resolve_provider = str ( model_cfg [ " provider " ] ) . strip ( )
if not resolve_base_url and model_cfg . get ( " base_url " ) :
resolve_base_url = str ( model_cfg [ " base_url " ] ) . strip ( )
2026-05-23 09:59:45 +00:00
supported_efforts = resolve_model_reasoning_efforts (
2026-05-27 09:54:45 +00:00
resolve_model ,
provider_id = resolve_provider ,
base_url = resolve_base_url ,
2026-05-23 09:59:45 +00:00
)
2026-04-21 22:26:52 +00:00
return {
# Match CLI default (True if unset in config.yaml)
" show_reasoning " : bool ( show_raw ) if isinstance ( show_raw , bool ) else True ,
2026-06-13 18:50:00 +00:00
# Report the COERCED effort (not the raw config value) so boot/status/chip
# read paths agree with what streaming actually sends — e.g. a stale
# `reasoning_effort: max` surfaces as `xhigh`, not the now-unsupported `max`.
# (Codex review of the drop-max alignment.)
" reasoning_effort " : coerce_reasoning_effort_for_model (
str ( effort_raw or " " ) . strip ( ) . lower ( ) ,
resolve_model ,
provider_id = resolve_provider ,
base_url = resolve_base_url ,
) ,
2026-05-23 09:59:45 +00:00
" supported_efforts " : supported_efforts ,
" supports_reasoning_effort " : bool ( supported_efforts ) ,
2026-04-21 22:26:52 +00:00
}
2026-06-27 20:27:18 +00:00
def _parse_positive_int_config_value ( raw ) - > int | None :
if raw is None :
return None
try :
parsed = int ( raw )
except ( TypeError , ValueError ) :
return None
return parsed if parsed > 0 else None
def get_max_tokens_status ( ) - > dict [ str , int | None ] :
""" Return the Settings-facing max_tokens state from the active profile config.
` ` max_tokens ` ` is the root override the Settings field owns directly .
` ` max_tokens_fallback ` ` is the agent - level fallback when the root config
resolves to ` ` None ` ` , matching the streaming path exactly .
` ` max_tokens_effective ` ` is the runtime cap a new streaming turn would
currently use .
"""
config_data = _load_yaml_config_file ( _get_config_path ( ) )
if not isinstance ( config_data , dict ) :
return {
" max_tokens " : None ,
" max_tokens_effective " : None ,
" max_tokens_fallback " : None ,
}
raw_root_value = config_data . get ( " max_tokens " )
root_value = _parse_positive_int_config_value ( raw_root_value )
fallback_value = None
if raw_root_value is None :
agent_cfg = config_data . get ( " agent " )
if isinstance ( agent_cfg , dict ) :
fallback_value = _parse_positive_int_config_value ( agent_cfg . get ( " max_tokens " ) )
effective_value = root_value if root_value is not None else fallback_value
return {
" max_tokens " : root_value ,
" max_tokens_effective " : effective_value ,
" max_tokens_fallback " : fallback_value ,
}
def set_max_tokens ( max_tokens ) - > dict [ str , int | None ] :
""" Persist a root-level ``max_tokens`` override to the active profile config.
Blank / ` ` None ` ` clears the root override so ` ` agent . max_tokens ` ` can resume .
Positive integers are written to the active profile ' s ``config.yaml``.
Unrelated YAML keys are preserved verbatim .
"""
if isinstance ( max_tokens , str ) :
max_tokens = max_tokens . strip ( )
clear_root = max_tokens in ( None , " " )
parsed_max_tokens = _parse_positive_int_config_value ( max_tokens )
if not clear_root and parsed_max_tokens is None :
return get_max_tokens_status ( )
config_path = _get_config_path ( )
should_save = True
with _cfg_lock :
config_data = _load_yaml_config_file_raw ( config_path )
if clear_root :
if " max_tokens " not in config_data :
should_save = False
else :
config_data . pop ( " max_tokens " , None )
elif parsed_max_tokens is not None :
config_data [ " max_tokens " ] = parsed_max_tokens
if should_save :
_save_yaml_config_file ( config_path , config_data )
if not should_save :
return get_max_tokens_status ( )
reload_config ( )
return get_max_tokens_status ( )
2026-04-21 22:26:52 +00:00
def set_reasoning_display ( show : bool ) - > dict :
""" Persist ``display.show_reasoning`` to the active profile ' s config.yaml.
Mirrors CLI ` ` / reasoning show | hide ` ` : writes the same key that the CLI
writes , so the preference is shared across the WebUI and the terminal
REPL for the same profile .
"""
config_path = _get_config_path ( )
with _cfg_lock :
config_data = _load_yaml_config_file ( config_path )
display_cfg = config_data . get ( " display " )
if not isinstance ( display_cfg , dict ) :
display_cfg = { }
display_cfg [ " show_reasoning " ] = bool ( show )
config_data [ " display " ] = display_cfg
_save_yaml_config_file ( config_path , config_data )
reload_config ( )
return get_reasoning_status ( )
2026-06-11 22:46:17 +00:00
def set_reasoning_effort (
effort : str ,
* ,
model_id : str | None = None ,
provider_id : str | None = None ,
base_url : str | None = None ,
) - > dict :
2026-04-21 22:26:52 +00:00
""" Persist ``agent.reasoning_effort`` to the active profile ' s config.yaml.
Mirrors CLI ` ` / reasoning < level > ` ` : same key , same valid values
( ` ` none ` ` | ` ` minimal ` ` | ` ` low ` ` | ` ` medium ` ` | ` ` high ` ` | ` ` xhigh ` ` ) .
Raises ` ` ValueError ` ` on an unrecognised level so callers can return 400.
"""
raw = str ( effort or " " ) . strip ( ) . lower ( )
if not raw :
raise ValueError ( " effort is required " )
if raw != " none " and raw not in VALID_REASONING_EFFORTS :
raise ValueError (
f " Unknown reasoning effort ' { effort } ' . "
f " Valid: none, { ' , ' . join ( VALID_REASONING_EFFORTS ) } . "
)
config_path = _get_config_path ( )
with _cfg_lock :
config_data = _load_yaml_config_file ( config_path )
agent_cfg = config_data . get ( " agent " )
if not isinstance ( agent_cfg , dict ) :
agent_cfg = { }
agent_cfg [ " reasoning_effort " ] = raw
config_data [ " agent " ] = agent_cfg
_save_yaml_config_file ( config_path , config_data )
reload_config ( )
2026-06-11 22:46:17 +00:00
return get_reasoning_status (
model_id = model_id ,
provider_id = provider_id ,
base_url = base_url ,
)
2026-04-21 22:26:52 +00:00
2026-06-15 20:16:34 +00:00
def _public_advanced_model_options ( model_cfg : dict ) - > dict :
""" Return write-only-safe advanced options from a model config block. """
if not isinstance ( model_cfg , dict ) :
model_cfg = { }
return {
" base_url " : str ( model_cfg . get ( " base_url " ) or " " ) . strip ( ) ,
" timeout " : model_cfg . get ( " timeout " , " " ) ,
" download_timeout " : model_cfg . get ( " download_timeout " , " " ) ,
" max_concurrency " : model_cfg . get ( " max_concurrency " , " " ) ,
" extra_body " : model_cfg . get ( " extra_body " ) if isinstance ( model_cfg . get ( " extra_body " ) , dict ) else { } ,
" api_key_set " : bool ( str ( model_cfg . get ( " api_key " ) or " " ) . strip ( ) ) ,
}
2026-06-20 06:22:56 +00:00
def _is_openai_family_provider ( provider : str | None ) - > bool :
""" Return True when a provider should receive OpenAI-family request overrides. """
if not provider :
return False
resolved = str ( _resolve_provider_alias ( str ( provider ) . strip ( ) . lower ( ) ) )
return resolved in ( " openai " , " openai-api " , " openai-codex " )
2026-06-20 06:46:05 +00:00
def _main_model_supports_service_tier (
model_id : str | None ,
provider : str | None ,
) - > bool :
""" Return True when the current main-model selection can use OpenAI service tier. """
if not _is_openai_family_provider ( provider ) :
return False
2026-06-21 03:56:27 +00:00
resolved = str ( provider or " " ) . strip ( ) . lower ( )
if resolved == " openai-codex " :
return False
2026-06-20 06:46:05 +00:00
raw_model = str ( model_id or " " ) . strip ( ) . lower ( )
if not raw_model :
return True
if " / " in raw_model :
prefix , bare_model = raw_model . split ( " / " , 1 )
if prefix != " openai " :
return False
else :
bare_model = raw_model
2026-06-20 07:39:17 +00:00
if " codex " in bare_model :
return False
2026-06-20 06:46:05 +00:00
return (
_is_first_party_model ( " openai " , bare_model )
or bare_model . startswith ( ( " gpt- " , " o1 " , " o3 " , " o4 " ) )
)
2026-06-20 06:22:56 +00:00
def _public_main_service_tier ( model_cfg : dict ) - > str :
""" Return the saved main-model service tier only for OpenAI-family providers. """
if not isinstance ( model_cfg , dict ) :
return " "
2026-06-20 06:46:05 +00:00
model_id = str ( model_cfg . get ( " default " ) or model_cfg . get ( " name " ) or " " ) . strip ( )
2026-06-20 06:22:56 +00:00
provider = str ( model_cfg . get ( " provider " ) or " " ) . strip ( ) . lower ( )
if not provider :
2026-06-20 06:46:05 +00:00
_ , provider , _ = resolve_model_provider ( model_id )
if not _main_model_supports_service_tier ( model_id , provider ) :
2026-06-20 06:22:56 +00:00
return " "
service_tier = str ( model_cfg . get ( " service_tier " ) or " " ) . strip ( ) . lower ( )
return " priority " if service_tier == " priority " else " "
2026-06-21 03:41:31 +00:00
def _main_model_request_overrides (
config_data : dict ,
effective_model : str | None = None ,
effective_provider : str | None = None ,
) - > dict :
""" Return supported runtime request overrides for the main chat model.
When * effective_model * / * effective_provider * are supplied , the
service - tier gate checks those instead of the saved default model ,
so a per - session model switch to a non - OpenAI provider does not
leak ` ` service_tier ` ` onto an unsupported request .
"""
2026-06-15 20:16:34 +00:00
if not isinstance ( config_data , dict ) :
return { }
model_cfg = config_data . get ( " model " , { } )
if not isinstance ( model_cfg , dict ) :
return { }
overrides = { }
2026-06-21 03:41:31 +00:00
gate_model = effective_model
gate_provider = effective_provider
if not gate_model :
gate_model = str ( model_cfg . get ( " default " ) or model_cfg . get ( " name " ) or " " ) . strip ( )
if not gate_provider :
gate_provider = str ( model_cfg . get ( " provider " ) or " " ) . strip ( ) . lower ( )
if not gate_provider :
_ , gate_provider , _ = resolve_model_provider ( gate_model )
if _main_model_supports_service_tier ( gate_model , gate_provider ) :
2026-06-20 06:22:56 +00:00
service_tier = str ( model_cfg . get ( " service_tier " ) or " " ) . strip ( ) . lower ( )
if service_tier == " priority " :
overrides [ " service_tier " ] = " priority "
2026-06-15 20:16:34 +00:00
extra_body = model_cfg . get ( " extra_body " )
if isinstance ( extra_body , dict ) and extra_body :
overrides [ " extra_body " ] = copy . deepcopy ( extra_body )
return overrides
def _apply_advanced_model_options ( model_cfg : dict , advanced : dict | None ) - > None :
""" Apply supported advanced model options to a config block in-place. """
if advanced is None :
return
if not isinstance ( advanced , dict ) :
raise ValueError ( " advanced model options must be an object " )
if " base_url " in advanced :
base_url = str ( advanced . get ( " base_url " ) or " " ) . strip ( ) . rstrip ( " / " )
if base_url :
model_cfg [ " base_url " ] = base_url
else :
model_cfg . pop ( " base_url " , None )
for field in ( " timeout " , " download_timeout " , " max_concurrency " ) :
if field in advanced :
coerced = _coerce_optional_positive_int ( advanced . get ( field ) , field )
if coerced == " " :
model_cfg . pop ( field , None )
elif coerced is not None :
model_cfg [ field ] = coerced
if " extra_body " in advanced :
extra_body = advanced . get ( " extra_body " )
if isinstance ( extra_body , str ) :
text = extra_body . strip ( )
try :
extra_body = json . loads ( text ) if text else { }
except json . JSONDecodeError as exc :
raise ValueError ( " extra_body must be valid JSON " ) from exc
if extra_body in ( None , " " ) :
model_cfg . pop ( " extra_body " , None )
elif isinstance ( extra_body , dict ) :
if extra_body :
model_cfg [ " extra_body " ] = extra_body
else :
model_cfg . pop ( " extra_body " , None )
else :
raise ValueError ( " extra_body must be a JSON object " )
2026-06-20 06:22:56 +00:00
if " service_tier " in advanced :
service_tier = str ( advanced . get ( " service_tier " ) or " " ) . strip ( ) . lower ( )
if not service_tier or service_tier == " default " :
model_cfg . pop ( " service_tier " , None )
elif service_tier == " priority " :
model_cfg [ " service_tier " ] = " priority "
else :
raise ValueError ( " service_tier must be one of: default, priority " )
2026-06-15 20:16:34 +00:00
if advanced . get ( " api_key_clear " ) :
model_cfg . pop ( " api_key " , None )
api_key = str ( advanced . get ( " api_key " ) or " " ) . strip ( )
if api_key :
model_cfg [ " api_key " ] = api_key
2026-06-24 19:10:51 +00:00
def set_hermes_default_model ( model_id : str , provider : str | None = None , advanced : dict | None = None ) - > dict :
2026-04-20 22:12:01 +00:00
""" Persist the Hermes default model in config.yaml and reload runtime config. """
selected_model = str ( model_id or " " ) . strip ( )
if not selected_model :
raise ValueError ( " model is required " )
config_path = _get_config_path ( )
# Hold _cfg_lock only around the read-modify-write of the YAML file.
# reload_config() acquires _cfg_lock internally (it's not reentrant) so
# it must be called AFTER releasing the lock to avoid deadlock.
with _cfg_lock :
config_data = _load_yaml_config_file ( config_path )
model_cfg = config_data . get ( " model " , { } )
if not isinstance ( model_cfg , dict ) :
model_cfg = { }
previous_provider = str ( model_cfg . get ( " provider " ) or " " ) . strip ( )
2026-06-24 19:10:51 +00:00
requested_provider = str ( provider or " " ) . strip ( )
2026-04-20 22:12:01 +00:00
resolved_model , resolved_provider , resolved_base_url = resolve_model_provider (
selected_model
)
2026-04-23 17:44:10 +00:00
# Persist the resolved bare/slash form, NOT the `@provider:` prefix. The
# prefix is a WebUI-internal routing hint that the hermes-agent CLI does
# not understand — if we wrote `@nous:anthropic/claude-opus-4.6` to
# config.yaml, a user who ran `hermes` in the terminal right after
# saving via WebUI would have the agent send that literal string to the
# Nous API, which would reject it (Nous expects `anthropic/claude-opus-4.6`,
# not the prefixed form). The Settings picker handles the resulting
# CLI-shaped bare form via `_applyModelToDropdown()`'s normalising
# matcher — see `static/panels.js` (#895).
2026-04-20 22:12:01 +00:00
persisted_model = str ( resolved_model or selected_model ) . strip ( )
2026-06-24 19:10:51 +00:00
persisted_provider = str ( requested_provider or resolved_provider or previous_provider or " " ) . strip ( )
2026-06-24 19:18:48 +00:00
provider_override_won = bool ( requested_provider and requested_provider != str ( resolved_provider or " " ) . strip ( ) )
2026-05-01 05:29:42 +00:00
# Never persist the bogus ``local`` value — see #1384. The auto-detect
# block in ``_build_available_models_uncached`` was rewriting unknown
# loopback hosts to ``provider: "local"``, which is not registered and
# broke compression/vision mid-conversation. Route through ``custom``
# so the agent's auxiliary client uses the ``no-key-required`` path.
if persisted_provider . lower ( ) == " local " :
persisted_provider = " custom "
2026-04-20 22:12:01 +00:00
model_cfg [ " default " ] = persisted_model
if persisted_provider :
model_cfg [ " provider " ] = persisted_provider
2026-06-24 19:18:48 +00:00
if resolved_base_url and not provider_override_won :
2026-04-20 22:12:01 +00:00
model_cfg [ " base_url " ] = str ( resolved_base_url ) . strip ( ) . rstrip ( " / " )
elif persisted_provider != previous_provider :
if persisted_provider == " openai " :
model_cfg [ " base_url " ] = " https://api.openai.com/v1 "
2026-06-24 20:17:29 +00:00
else :
# Provider changed and we have no resolved URL for the new one.
# Drop the previous provider's base_url so New Chat doesn't route
# to the old endpoint — this MUST also cover custom:* providers
# (a different custom provider has a different URL); leaving the
# stale base_url sent requests to the wrong host (#4728).
2026-04-20 22:12:01 +00:00
model_cfg . pop ( " base_url " , None )
2026-06-15 20:16:34 +00:00
_apply_advanced_model_options ( model_cfg , advanced )
2026-06-20 06:46:05 +00:00
if not _main_model_supports_service_tier ( persisted_model , persisted_provider ) :
2026-06-20 06:36:53 +00:00
model_cfg . pop ( " service_tier " , None )
2026-06-15 20:16:34 +00:00
2026-04-20 22:12:01 +00:00
config_data [ " model " ] = model_cfg
_save_yaml_config_file ( config_path , config_data )
# Reload outside the lock — reload_config() acquires _cfg_lock itself.
reload_config ( )
2026-04-23 17:44:10 +00:00
# Invalidate the TTL cache so the next /api/models call returns fresh data
# with the new default model. Do NOT call get_available_models() here —
# it triggers a live provider fetch (up to 8s) that blocks the HTTP response
# to the browser, causing a visible freeze on every Settings save (#895).
2026-04-21 02:32:33 +00:00
invalidate_models_cache ( )
2026-06-24 19:10:51 +00:00
return { " ok " : True , " model " : persisted_model , " provider " : persisted_provider or None }
2026-04-20 22:12:01 +00:00
2026-05-24 18:28:26 +00:00
# ── Auxiliary model configuration ──────────────────────────────────────────
# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py
# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS.
AUX_TASK_SLOTS : tuple [ str , . . . ] = (
" vision " ,
" web_extract " ,
" compression " ,
" session_search " ,
" skills_hub " ,
" approval " ,
" mcp " ,
" title_generation " ,
" curator " ,
2026-06-15 20:16:34 +00:00
" kanban_decomposer " ,
" profile_describer " ,
" triage_specifier " ,
2026-05-24 18:28:26 +00:00
)
def get_auxiliary_models ( ) - > dict :
""" Return current auxiliary task assignments from config.yaml.
Shape :
{
" tasks " : [
{ " task " : " vision " , " provider " : " auto " , " model " : " " , " base_url " : " " } ,
. . .
] ,
2026-06-20 06:22:56 +00:00
" main " : { " provider " : " openrouter " , " model " : " anthropic/claude-opus-4.7 " , " service_tier " : " " } ,
2026-05-24 18:28:26 +00:00
}
"""
reload_config ( )
model_cfg = cfg . get ( " model " , { } )
if not isinstance ( model_cfg , dict ) :
model_cfg = { }
main_provider = str ( model_cfg . get ( " provider " ) or " " ) . strip ( )
main_model = str ( model_cfg . get ( " default " ) or model_cfg . get ( " name " ) or " " ) . strip ( )
aux_cfg = cfg . get ( " auxiliary " , { } )
if not isinstance ( aux_cfg , dict ) :
aux_cfg = { }
tasks = [ ]
for slot in AUX_TASK_SLOTS :
entry = aux_cfg . get ( slot , { } )
if not isinstance ( entry , dict ) :
entry = { }
tasks . append ( {
" task " : slot ,
" provider " : str ( entry . get ( " provider " ) or " auto " ) . strip ( ) ,
" model " : str ( entry . get ( " model " ) or " " ) . strip ( ) ,
" base_url " : str ( entry . get ( " base_url " ) or " " ) . strip ( ) ,
2026-06-15 20:16:34 +00:00
" timeout " : entry . get ( " timeout " , " " ) ,
" download_timeout " : entry . get ( " download_timeout " , " " ) ,
" max_concurrency " : entry . get ( " max_concurrency " , " " ) ,
" extra_body " : entry . get ( " extra_body " ) if isinstance ( entry . get ( " extra_body " ) , dict ) else { } ,
" api_key_set " : bool ( str ( entry . get ( " api_key " ) or " " ) . strip ( ) ) ,
2026-05-24 18:28:26 +00:00
} )
return {
" tasks " : tasks ,
2026-06-15 20:16:34 +00:00
" main " : {
" provider " : main_provider ,
" model " : main_model ,
2026-06-20 06:22:56 +00:00
" service_tier " : _public_main_service_tier ( model_cfg ) ,
2026-06-15 20:16:34 +00:00
* * _public_advanced_model_options ( model_cfg ) ,
} ,
2026-05-24 18:28:26 +00:00
}
2026-06-15 20:16:34 +00:00
def _coerce_optional_positive_int ( value , field : str ) :
if value is None :
return None
if isinstance ( value , str ) :
value = value . strip ( )
if value == " " :
return " "
try :
number = int ( value )
except ( TypeError , ValueError ) as exc :
raise ValueError ( f " { field } must be a positive integer " ) from exc
if number < 1 :
raise ValueError ( f " { field } must be a positive integer " )
return number
def set_auxiliary_model ( task : str , provider : str , model : str , advanced : dict | None = None ) - > dict :
2026-05-24 18:28:26 +00:00
""" Persist an auxiliary model assignment in config.yaml.
Special case : task = ' __reset__ ' clears all auxiliary slots .
2026-06-15 20:16:34 +00:00
` ` advanced ` ` may update per - slot fields surfaced behind the WebUI gear menu .
Sensitive api_key values are write - only : get_auxiliary_models ( ) only reports
whether one is set .
2026-05-24 18:28:26 +00:00
"""
2026-05-25 00:46:48 +00:00
if task != " __reset__ " and task not in AUX_TASK_SLOTS :
raise ValueError (
f " Unknown auxiliary task slot: { task !r} . Valid: { list ( AUX_TASK_SLOTS ) } "
)
2026-05-24 18:28:26 +00:00
config_path = _get_config_path ( )
with _cfg_lock :
config_data = _load_yaml_config_file ( config_path )
if task == " __reset__ " :
# Per-slot reset: set each slot to auto, preserving extra fields
# (timeout, extra_body, api_key, base_url, download_timeout, etc.)
aux_cfg = config_data . get ( " auxiliary " , { } )
if not isinstance ( aux_cfg , dict ) :
aux_cfg = { }
for slot in AUX_TASK_SLOTS :
slot_cfg = aux_cfg . get ( slot , { } )
if not isinstance ( slot_cfg , dict ) :
slot_cfg = { }
slot_cfg [ " provider " ] = " auto "
slot_cfg [ " model " ] = " "
aux_cfg [ slot ] = slot_cfg
config_data [ " auxiliary " ] = aux_cfg
else :
aux_cfg = config_data . get ( " auxiliary " , { } )
if not isinstance ( aux_cfg , dict ) :
aux_cfg = { }
slot_cfg = aux_cfg . get ( task , { } )
if not isinstance ( slot_cfg , dict ) :
slot_cfg = { }
slot_cfg [ " provider " ] = provider or " auto "
slot_cfg [ " model " ] = model or " "
if provider and ( provider . startswith ( " custom: " ) or provider == " custom " ) :
try :
_ , _ , resolved_base_url = resolve_model_provider ( model )
if resolved_base_url :
slot_cfg [ " base_url " ] = str ( resolved_base_url ) . strip ( ) . rstrip ( " / " )
except Exception :
pass
2026-06-15 20:16:34 +00:00
if advanced is not None :
try :
_apply_advanced_model_options ( slot_cfg , advanced )
except ValueError as exc :
msg = str ( exc ) . replace ( " advanced model options " , " advanced auxiliary options " )
raise ValueError ( msg ) from exc
2026-05-24 18:28:26 +00:00
aux_cfg [ task ] = slot_cfg
config_data [ " auxiliary " ] = aux_cfg
_save_yaml_config_file ( config_path , config_data )
reload_config ( )
return { " ok " : True , " task " : task , " provider " : provider , " model " : model }
2026-04-21 00:33:03 +00:00
# ── TTL cache for get_available_models() ─────────────────────────────────────
_available_models_cache : dict | None = None
_available_models_cache_ts : float = 0.0
2026-06-27 10:36:18 +00:00
_available_models_live_rebuild_ts : float = 0.0
2026-05-05 15:33:44 +00:00
_available_models_cache_source_fingerprint : dict | None = None
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_AVAILABLE_MODELS_CACHE_TTL : float = 86400.0 # 24 hours
2026-06-23 16:18:44 +00:00
_SESSION_VISIT_MODELS_FRESHNESS_SECONDS : float = 300.0
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_available_models_cache_lock = threading . RLock ( ) # must be RLock: cold path refactoring moved slow work inside this lock, requiring re-entry
_cache_build_cv = threading . Condition ( _available_models_cache_lock ) # shares underlying RLock so notify_all() is safe inside with _available_models_cache_lock
_cache_build_in_progress = False # True while a cold path is actively building
2026-06-09 05:36:18 +00:00
# Hard wall-clock budget for a COLD live provider-catalog rebuild when it is
# run from a foreground request path. The live rebuild does one network probe
# per detected provider (Copilot token-exchange HTTPS, OpenRouter /v1/models,
# Nous /models, ...). On a flaky / corp / WSL network any single probe can
# stall for its full per-call timeout (Copilot urllib timeout=10s) and, summed
# across N providers, block the request thread for tens of seconds. This bounds
# the time a foreground caller will wait: past the budget it returns a usable
# fallback (last-known disk cache or a network-free minimal catalog) and lets
# the rebuild finish out-of-band and populate the cache for the next call.
# Set HERMES_WEBUI_MODELS_REBUILD_BUDGET=0 to restore the legacy synchronous
# (unbounded) behaviour.
try :
_LIVE_REBUILD_BUDGET_SECONDS : float = float (
os . getenv ( " HERMES_WEBUI_MODELS_REBUILD_BUDGET " , " 4 " ) or " 4 "
)
except ( TypeError , ValueError ) :
_LIVE_REBUILD_BUDGET_SECONDS = 4.0
# ── Budget-exceeded warning rate-limit ───────────────────────────────────────
# Q-2979-A3 / Copilot discussion_r3305864400: the live-rebuild-budget-exceeded
# warning at _invoke_models_rebuild's slow-path is potentially high-volume —
# every provider catalog refresh that runs past _LIVE_REBUILD_BUDGET_SECONDS
# emits one, so a hung upstream probe (or a sustained burst of cold callers)
# could flood the log at warning level. Rate-limit per reason: the FIRST
# occurrence in a cooldown window logs at warning; subsequent occurrences in
# the same window log at info (so log signal stays useful but volume bounded).
# Override the default cooldown via HERMES_WEBUI_BUDGET_WARN_COOLDOWN (seconds).
try :
_BUDGET_WARN_COOLDOWN_SECONDS : float = float (
os . getenv ( " HERMES_WEBUI_BUDGET_WARN_COOLDOWN " , " 300 " ) or " 300 "
)
except ( TypeError , ValueError ) :
_BUDGET_WARN_COOLDOWN_SECONDS = 300.0
_BUDGET_WARN_STATE : dict [ str , float ] = { }
_BUDGET_WARN_LOCK = threading . Lock ( )
def _should_warn_budget ( reason : str , cooldown_s : float | None = None ) - > bool :
""" Return True iff the budget warning for ``reason`` should log at
warning level ( first hit , or last warn - level emit was more than
` ` cooldown_s ` ` seconds ago ) . Otherwise False — the caller should demote
to info for the same payload so the signal is retained but the noise is
capped . Thread - safe ; the cooldown is shared across all live - rebuild
callers in this process .
"""
cooldown = (
_BUDGET_WARN_COOLDOWN_SECONDS if cooldown_s is None else float ( cooldown_s )
)
now = time . monotonic ( )
with _BUDGET_WARN_LOCK :
last = _BUDGET_WARN_STATE . get ( reason )
if last is None or ( now - last ) > = cooldown :
_BUDGET_WARN_STATE [ reason ] = now
return True
return False
def _invoke_models_rebuild ( builder ) :
""" Indirection seam around the cold catalog rebuild.
Production simply calls ` ` builder ( ) ` ` . Exists so tests can simulate a
slow / hanging provider probe without having to reach the closure that
actually does the per - provider network calls .
"""
return builder ( )
2026-06-12 11:49:29 +00:00
def _configured_model_badges_from_static_catalog (
groups : list [ dict ] ,
* ,
active_provider : str | None ,
default_model : str ,
) - > dict [ str , dict [ str , str ] ] :
configured_entries : list [ dict [ str , str ] ] = [ ]
if active_provider and default_model :
configured_entries . append (
{
" provider " : active_provider ,
" model " : default_model ,
" role " : " primary " ,
" label " : " Primary " ,
}
)
fallback_cfg = cfg . get ( " fallback_providers " , [ ] ) if isinstance ( cfg , dict ) else [ ]
if isinstance ( fallback_cfg , list ) :
for idx , entry in enumerate ( fallback_cfg , start = 1 ) :
if not isinstance ( entry , dict ) :
continue
provider = _resolve_provider_alias ( entry . get ( " provider " ) )
model = str ( entry . get ( " model " ) or " " ) . strip ( )
if not provider or not model :
continue
configured_entries . append (
{
" provider " : provider ,
" model " : model ,
" role " : " fallback " ,
" label " : f " Fallback { idx } " ,
}
)
option_ids = [
m . get ( " id " , " " )
for g in groups
for m in g . get ( " models " , [ ] )
if m . get ( " id " )
]
option_lookup = { str ( opt_id ) : str ( opt_id ) for opt_id in option_ids }
option_provider_lookup = {
str ( m . get ( " id " ) ) : str ( g . get ( " provider_id " ) or " " )
for g in groups
for m in g . get ( " models " , [ ] )
if m . get ( " id " )
}
2026-06-12 12:43:06 +00:00
def _norm_static_model_id ( model_id : str ) - > str :
2026-06-12 11:49:29 +00:00
s = str ( model_id or " " ) . strip ( ) . lower ( )
2026-06-14 23:27:39 +00:00
stripped_at_provider = False
2026-06-12 11:49:29 +00:00
if s . startswith ( " @ " ) and " : " in s :
2026-06-14 23:27:39 +00:00
colon_idx = s . index ( " : " , 1 )
candidate = s [ colon_idx + 1 : ]
stripped_at_provider = bool ( candidate )
s = candidate or s
if " :// " not in s :
if (
not stripped_at_provider
and " / " in s
and " : " in s
and s . index ( " : " ) < s . index ( " / " )
) :
s = s [ s . index ( " / " ) + 1 : ] or s
if " / " in s :
stripped = s . split ( " / " , 1 ) [ 1 ]
s = stripped or s
2026-06-12 11:49:29 +00:00
return s . replace ( " - " , " . " )
norm_lookup : dict [ str , list [ str ] ] = { }
for opt_id in option_ids :
2026-06-12 12:43:06 +00:00
norm_lookup . setdefault ( _norm_static_model_id ( opt_id ) , [ ] ) . append ( opt_id )
2026-06-12 11:49:29 +00:00
badges : dict [ str , dict [ str , str ] ] = { }
for entry in configured_entries :
provider = entry [ " provider " ]
model = entry [ " model " ]
raw_candidates = [ ]
for candidate in ( model , f " { provider } / { model } " , f " @ { provider } : { model } " ) :
if candidate and candidate not in raw_candidates :
raw_candidates . append ( candidate )
match_id = None
for candidate in raw_candidates :
if (
candidate in option_lookup
and option_provider_lookup . get ( candidate ) == provider
) :
match_id = option_lookup [ candidate ]
break
if match_id is None :
for candidate in raw_candidates :
2026-06-12 12:43:06 +00:00
normalized = _norm_static_model_id ( candidate )
2026-06-12 11:49:29 +00:00
matches = norm_lookup . get ( normalized , [ ] )
if not matches :
continue
provider_match = next (
( m for m in matches if option_provider_lookup . get ( m ) == provider ) ,
None ,
)
match_id = provider_match or matches [ 0 ]
if match_id :
break
badge_payload = {
" role " : entry [ " role " ] ,
" label " : entry [ " label " ] ,
" provider " : provider ,
}
for candidate in raw_candidates :
candidate_provider = option_provider_lookup . get ( candidate )
if candidate_provider and candidate_provider != provider :
continue
badges [ candidate ] = badge_payload
if match_id :
badges [ match_id ] = badge_payload
return badges
2026-06-09 05:36:18 +00:00
def _minimal_static_models_catalog ( ) - > dict :
2026-06-12 11:49:29 +00:00
""" Return the emergency one-model fallback for /api/models. """
2026-06-09 05:36:18 +00:00
try :
active_provider = None
cfg_base_url = " "
model_cfg = cfg . get ( " model " , { } ) if isinstance ( cfg , dict ) else { }
if isinstance ( model_cfg , dict ) :
active_provider = model_cfg . get ( " provider " )
cfg_base_url = model_cfg . get ( " base_url " , " " ) or " "
if active_provider :
try :
active_provider = _resolve_configured_provider_id (
active_provider , cfg , base_url = cfg_base_url
)
except Exception :
active_provider = str ( active_provider or " " ) . strip ( ) or None
if not active_provider :
try :
_ap = _get_auth_store_path ( )
if _ap . exists ( ) :
_store = json . loads ( _ap . read_text ( encoding = " utf-8 " ) )
active_provider = (
_resolve_configured_provider_id (
_store . get ( " active_provider " ) , cfg , base_url = cfg_base_url
)
or None
)
except Exception :
pass
default_model = get_effective_default_model ( cfg )
groups : list [ dict ] = [ ]
if default_model :
try :
label = _get_label_for_model ( default_model , [ ] )
except Exception :
label = default_model
groups . append (
{
" provider " : " Default " ,
" provider_id " : active_provider or " default " ,
" models " : [ { " id " : default_model , " label " : label } ] ,
}
)
return {
" active_provider " : active_provider ,
" default_model " : default_model ,
" configured_model_badges " : { } ,
" groups " : groups ,
2026-06-12 15:55:43 +00:00
" aliases " : { } ,
2026-06-09 05:36:18 +00:00
}
except Exception :
logger . debug ( " minimal static models catalog build failed " , exc_info = True )
return {
" active_provider " : None ,
" default_model " : " " ,
" configured_model_badges " : { } ,
" groups " : [ ] ,
2026-06-12 15:55:43 +00:00
" aliases " : { } ,
2026-06-09 05:36:18 +00:00
}
2026-06-12 11:49:29 +00:00
def _static_models_catalog_without_live_probes ( ) - > dict :
""" Return a network-free /api/models catalog from local config/auth only. """
try :
from api . providers import _provider_has_key
active_provider = None
cfg_base_url = " "
model_cfg = cfg . get ( " model " , { } ) if isinstance ( cfg , dict ) else { }
if isinstance ( model_cfg , dict ) :
active_provider = model_cfg . get ( " provider " )
cfg_base_url = model_cfg . get ( " base_url " , " " ) or " "
if active_provider :
try :
active_provider = _resolve_configured_provider_id (
active_provider ,
cfg ,
base_url = cfg_base_url ,
)
except Exception :
active_provider = str ( active_provider or " " ) . strip ( ) or None
auth_store : dict = { }
try :
auth_store_path = _get_auth_store_path ( )
if auth_store_path . exists ( ) :
auth_store = json . loads ( auth_store_path . read_text ( encoding = " utf-8 " ) )
if not active_provider :
active_provider = (
_resolve_configured_provider_id (
auth_store . get ( " active_provider " ) ,
cfg ,
base_url = cfg_base_url ,
)
or None
)
except Exception :
logger . debug ( " Failed to load auth store for static models catalog " , exc_info = True )
default_model = get_effective_default_model ( cfg )
detected_providers : set [ str ] = set ( )
configured_model_ids : dict [ str , list [ str ] ] = { }
named_custom_groups : dict [ str , dict [ str , object ] ] = { }
custom_group_models : list [ dict ] = [ ]
canonical_to_raw_provider_key : dict [ str , str ] = { }
providers_cfg = _get_providers_cfg ( )
def _append_model_id ( provider_id : str | None , model_id : object ) - > None :
pid = _canonicalise_provider_id ( provider_id )
mid = str ( model_id or " " ) . strip ( )
if not pid or not mid :
return
configured_model_ids . setdefault ( pid , [ ] )
if mid not in configured_model_ids [ pid ] :
configured_model_ids [ pid ] . append ( mid )
if active_provider :
detected_providers . add ( active_provider )
_append_model_id ( active_provider , default_model )
try :
_pool = auth_store . get ( " credential_pool " , { } ) if isinstance ( auth_store , dict ) else { }
if isinstance ( _pool , dict ) :
for _pid , _entries in _pool . items ( ) :
if not isinstance ( _entries , list ) or not _entries :
continue
if any (
isinstance ( _entry , dict )
and not _is_ambient_gh_cli_entry (
str ( _entry . get ( " source " , " " ) or " " ) ,
str ( _entry . get ( " label " , " " ) or " " ) ,
str ( _entry . get ( " key_source " , " " ) or " " ) ,
)
for _entry in _entries
) :
detected_providers . add ( _resolve_provider_alias ( str ( _pid ) ) )
except Exception :
logger . debug ( " Failed to inspect auth-store credential pool " , exc_info = True )
if isinstance ( providers_cfg , dict ) :
for provider_key , provider_cfg in providers_cfg . items ( ) :
canonical = _canonicalise_provider_id ( provider_key )
if not canonical :
continue
is_known_provider = (
canonical in _PROVIDER_MODELS
or canonical in _PROVIDER_DISPLAY
or _is_plugin_model_provider ( canonical )
)
is_provider_config = isinstance ( provider_cfg , dict )
if not ( is_known_provider or is_provider_config ) :
continue
canonical_to_raw_provider_key . setdefault ( canonical , provider_key )
if isinstance ( provider_cfg , dict ) :
has_local_signal = any (
str ( provider_cfg . get ( key ) or " " ) . strip ( )
for key in ( " api_key " , " key_env " , " base_url " )
)
provider_models = provider_cfg . get ( " models " )
if isinstance ( provider_models , dict ) :
for model_id in provider_models :
_append_model_id ( canonical , model_id )
has_local_signal = True
elif isinstance ( provider_models , list ) :
for item in provider_models :
if isinstance ( item , dict ) :
_append_model_id (
canonical ,
item . get ( " id " ) or item . get ( " model " ) or item . get ( " name " ) ,
)
else :
_append_model_id ( canonical , item )
has_local_signal = True
if has_local_signal :
detected_providers . add ( canonical )
for provider_id in set ( _PROVIDER_MODELS ) | set ( _PROVIDER_DISPLAY ) :
canonical = _canonicalise_provider_id ( provider_id )
if canonical and _provider_has_key ( canonical ) :
detected_providers . add ( canonical )
fallback_cfg = cfg . get ( " fallback_providers " , [ ] ) if isinstance ( cfg , dict ) else [ ]
if isinstance ( fallback_cfg , list ) :
for entry in fallback_cfg :
if not isinstance ( entry , dict ) :
continue
provider = _resolve_provider_alias ( entry . get ( " provider " ) )
if provider :
detected_providers . add ( provider )
_append_model_id ( provider , entry . get ( " model " ) )
for entry in _custom_provider_entries ( cfg ) :
provider_name = str ( entry . get ( " name " ) or " " ) . strip ( )
provider_slug = _custom_provider_slug_from_name ( provider_name ) or " custom "
if provider_slug != " custom " :
named_custom_groups . setdefault (
provider_slug ,
{ " name " : provider_name , " models " : [ ] } ,
)
detected_providers . add ( provider_slug )
configured_ids : list [ str ] = [ ]
model_id = str ( entry . get ( " model " ) or " " ) . strip ( )
if model_id :
configured_ids . append ( model_id )
raw_models = entry . get ( " models " )
if isinstance ( raw_models , dict ) :
for key in raw_models :
if isinstance ( key , str ) and key . strip ( ) and key . strip ( ) not in configured_ids :
configured_ids . append ( key . strip ( ) )
elif isinstance ( raw_models , list ) :
for item in raw_models :
if isinstance ( item , dict ) :
candidate = str (
item . get ( " id " ) or item . get ( " model " ) or item . get ( " name " ) or " "
) . strip ( )
else :
candidate = str ( item or " " ) . strip ( )
if candidate and candidate not in configured_ids :
configured_ids . append ( candidate )
for configured_id in configured_ids :
label = _get_label_for_model ( configured_id , [ ] )
if provider_slug == " custom " :
custom_group_models . append ( { " id " : configured_id , " label " : label } )
else :
named_custom_groups [ provider_slug ] [ " models " ] . append (
{ " id " : configured_id , " label " : label }
)
_append_model_id ( provider_slug , configured_id )
if cfg_base_url :
detected_providers . add (
_named_custom_provider_slug_for_base_url ( cfg_base_url , cfg )
or active_provider
or " custom "
)
if detected_providers :
detected_providers = {
_canonicalise_provider_id ( provider_id ) or provider_id
for provider_id in detected_providers
if provider_id
}
groups : list [ dict ] = [ ]
for pid in sorted ( detected_providers ) :
if pid . startswith ( " custom: " ) :
custom_group = named_custom_groups . get ( pid , { } )
group_models = copy . deepcopy ( custom_group . get ( " models " , [ ] ) )
if group_models or pid == active_provider :
groups . append (
{
" provider " : custom_group . get ( " name " ) or pid . replace ( " custom: " , " " ) ,
" provider_id " : pid ,
" models " : _apply_provider_prefix (
group_models ,
pid ,
active_provider ,
) ,
}
)
continue
if pid == " custom " :
group_models = copy . deepcopy ( custom_group_models )
for model_id in configured_model_ids . get ( pid , [ ] ) :
if not any ( m . get ( " id " ) == model_id for m in group_models ) :
group_models . append (
{ " id " : model_id , " label " : _get_label_for_model ( model_id , [ ] ) }
)
if group_models or cfg_base_url or pid == active_provider :
groups . append (
{
" provider " : _PROVIDER_DISPLAY . get ( pid , " Custom " ) ,
" provider_id " : pid ,
" models " : _apply_provider_prefix (
group_models ,
pid ,
active_provider ,
) ,
}
)
continue
provider_name = _PROVIDER_DISPLAY . get ( pid , pid . replace ( " - " , " " ) . title ( ) )
raw_key = canonical_to_raw_provider_key . get ( pid , pid )
provider_cfg = _get_provider_cfg ( raw_key )
raw_models = [ ]
if isinstance ( provider_cfg , dict ) and " models " in provider_cfg :
cfg_models = provider_cfg [ " models " ]
if isinstance ( cfg_models , dict ) :
raw_models = [ { " id " : key , " label " : key } for key in cfg_models . keys ( ) ]
elif isinstance ( cfg_models , list ) :
2026-06-12 20:05:12 +00:00
raw_models = [ ]
for item in cfg_models :
if isinstance ( item , dict ) :
model_id = (
item . get ( " id " )
or item . get ( " model " )
or item . get ( " name " )
)
if not model_id :
continue
raw_models . append (
{
" id " : model_id ,
" label " : item . get ( " label " , model_id ) ,
}
)
elif item :
raw_models . append ( { " id " : item , " label " : item } )
2026-06-12 11:49:29 +00:00
if not raw_models :
raw_models = copy . deepcopy ( _PROVIDER_MODELS . get ( pid , [ ] ) )
for model_id in configured_model_ids . get ( pid , [ ] ) :
if model_id and not any ( m . get ( " id " ) == model_id for m in raw_models ) :
raw_models . append (
{ " id " : model_id , " label " : _get_label_for_model ( model_id , groups ) }
)
if raw_models :
groups . append (
{
" provider " : provider_name ,
" provider_id " : pid ,
" models " : _apply_provider_prefix ( raw_models , pid , active_provider ) ,
}
)
if default_model :
all_model_ids = {
str ( model . get ( " id " ) or " " )
for group in groups
for model in group . get ( " models " , [ ] )
}
if default_model not in all_model_ids and f " @ { active_provider } : { default_model } " not in all_model_ids :
label = _get_label_for_model ( default_model , groups )
target_group = next (
( group for group in groups if group . get ( " provider_id " ) == active_provider ) ,
None ,
)
if target_group is not None :
target_group . setdefault ( " models " , [ ] ) . insert ( 0 , { " id " : default_model , " label " : label } )
elif groups :
groups . append (
{
" provider " : " Default " ,
" provider_id " : active_provider or " default " ,
" models " : [ { " id " : default_model , " label " : label } ] ,
}
)
_deduplicate_model_ids ( groups )
groups = [
group
for group in groups
if group . get ( " models " ) or str ( group . get ( " provider_id " ) or " " ) . startswith ( " custom: " )
]
providers_with_keys : set [ str ] = set ( )
try :
_pool = auth_store . get ( " credential_pool " , { } ) if isinstance ( auth_store , dict ) else { }
if isinstance ( _pool , dict ) :
for _pid in _pool :
_canonical = _canonicalise_provider_id ( _pid )
if _canonical :
providers_with_keys . add ( _canonical )
except Exception :
pass
try :
for _pk , _pv in providers_cfg . items ( ) :
if isinstance ( _pv , dict ) and (
_pv . get ( " api_key " )
or _pv . get ( " key_env " )
or _pv . get ( " base_url " )
) :
_canonical = _canonicalise_provider_id ( _pk )
if _canonical :
providers_with_keys . add ( _canonical )
except Exception :
pass
def _group_sort_key ( group : dict ) - > tuple [ int , str ] :
provider_id = str ( group . get ( " provider_id " ) or " " )
if provider_id == active_provider :
return ( 0 , provider_id )
if provider_id . startswith ( " custom: " ) :
return ( 1 , provider_id )
if provider_id in providers_with_keys :
return ( 2 , provider_id )
return ( 3 , provider_id )
groups . sort ( key = _group_sort_key )
model_aliases : dict [ str , str ] = { }
try :
raw_aliases = cfg . get ( " model " , { } ) . get ( " aliases " , { } )
if isinstance ( raw_aliases , dict ) :
model_aliases = {
str ( k ) . strip ( ) : str ( v ) . strip ( )
for k , v in raw_aliases . items ( )
if k and v
}
except Exception :
pass
if not groups and default_model :
return copy . deepcopy ( _minimal_static_models_catalog ( ) )
return {
" active_provider " : active_provider ,
" default_model " : default_model ,
" configured_model_badges " : _configured_model_badges_from_static_catalog (
groups ,
active_provider = active_provider ,
default_model = default_model ,
) ,
" groups " : groups ,
" aliases " : model_aliases ,
}
except Exception :
logger . debug ( " static models catalog build failed " , exc_info = True )
return copy . deepcopy ( _minimal_static_models_catalog ( ) )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# Cache for credential pool results -- calling load_pool() per-provider per-server
# session is expensive (~10s for zai due to endpoint probing). The credential pool
# only changes when the user adds/removes credentials, which is rare; a 24h TTL
# is plenty safe and ensures get_available_models() cold paths are fast.
2026-06-16 18:09:01 +00:00
_CREDENTIAL_POOL_CACHE : dict [ tuple [ str , str ] , tuple [ float , " CredentialPool " ] ] = { } # noqa: F821 forward-ref string annotation, resolved at runtime # (profile_tag, pid) -> (ts, pool)
def _credential_pool_profile_tag ( ) - > str :
""" Active-profile identity for the credential-pool cache key.
The credential pool is per - Hermes - profile ( it lives in that profile ' s
auth . json ) . Keying the process - global cache by provider id ALONE lets a
pool loaded under profile A satisfy a lookup under profile B in the same
server process — so a custom provider configured only in A would falsely
report configured in B ( and then 401 at request time ) . Scoping every
cache key by the active profile ' s auth-store path keeps pools from
crossing profile boundaries .
"""
try :
return str ( _get_auth_store_path ( ) )
except Exception :
return " "
2026-06-16 17:44:10 +00:00
2026-06-20 23:52:52 +00:00
def _pool_entry_payloads ( provider_id : str ) - > list [ dict [ str , Any ] ] :
""" Return explicit credential-pool entry payloads for the active profile.
2026-06-16 17:44:10 +00:00
2026-06-20 23:52:52 +00:00
Readonly profile scopes must not let ` ` load_pool ( ) ` ` seed from process env ,
because that can materialize server - default credentials into a named
profile ' s auth store. In that mode, read raw auth.json payloads only.
2026-06-16 17:44:10 +00:00
"""
2026-06-20 23:52:52 +00:00
_pid = _resolve_provider_alias ( provider_id )
if bool ( getattr ( _thread_ctx , " block_process_env_fallback " , False ) ) :
try :
from hermes_cli . auth import read_credential_pool as _read_credential_pool
raw_entries = _read_credential_pool ( _pid )
except ImportError :
return [ ]
payloads : list [ dict [ str , Any ] ] = [ ]
for entry in raw_entries :
if not isinstance ( entry , dict ) :
continue
if _is_ambient_gh_cli_entry (
str ( entry . get ( " source " , " " ) or " " ) ,
str ( entry . get ( " label " , " " ) or " " ) ,
str ( entry . get ( " key_source " , " " ) or " " ) ,
) :
continue
payloads . append ( dict ( entry ) )
return payloads
2026-06-16 17:44:10 +00:00
try :
from agent . credential_pool import load_pool as _load_pool
2026-06-16 18:09:01 +00:00
_ck = ( _credential_pool_profile_tag ( ) , _pid )
_cached = _CREDENTIAL_POOL_CACHE . get ( _ck )
2026-06-16 17:44:10 +00:00
if _cached is not None :
_cp_ts , _cp_pool = _cached
if ( time . time ( ) - _cp_ts ) < 86400.0 :
2026-06-20 23:58:04 +00:00
_all_entries = _cp_pool . entries ( ) if _cp_pool is not None and hasattr ( _cp_pool , " entries " ) else [ ]
2026-06-16 17:44:10 +00:00
else :
_cp_pool = _load_pool ( _pid )
2026-06-16 18:09:01 +00:00
_CREDENTIAL_POOL_CACHE [ _ck ] = ( time . time ( ) , _cp_pool )
2026-06-20 23:58:04 +00:00
_all_entries = _cp_pool . entries ( ) if _cp_pool is not None and hasattr ( _cp_pool , " entries " ) else [ ]
2026-06-16 17:44:10 +00:00
else :
_cp_pool = _load_pool ( _pid )
2026-06-16 18:09:01 +00:00
_CREDENTIAL_POOL_CACHE [ _ck ] = ( time . time ( ) , _cp_pool )
2026-06-20 23:58:04 +00:00
_all_entries = _cp_pool . entries ( ) if _cp_pool is not None and hasattr ( _cp_pool , " entries " ) else [ ]
2026-06-16 17:44:10 +00:00
except ImportError :
2026-06-20 23:52:52 +00:00
return [ ]
payloads = [ ]
for entry in _all_entries :
if _is_ambient_gh_cli_entry (
str ( getattr ( entry , " source " , " " ) or " " ) ,
str ( getattr ( entry , " label " , " " ) or " " ) ,
str ( getattr ( entry , " key_source " , " " ) or " " ) ,
) :
continue
2026-06-21 00:01:58 +00:00
if hasattr ( entry , " to_dict " ) and callable ( entry . to_dict ) :
2026-06-20 23:58:04 +00:00
payload = entry . to_dict ( )
elif isinstance ( entry , dict ) :
payload = dict ( entry )
else :
try :
payload = dict ( vars ( entry ) )
except TypeError :
payload = { }
2026-06-20 23:52:52 +00:00
if not isinstance ( payload , dict ) :
payload = { }
payload = dict ( payload )
payload . setdefault ( " source " , str ( getattr ( entry , " source " , " " ) or " " ) )
payload . setdefault ( " label " , str ( getattr ( entry , " label " , " " ) or " " ) )
payload . setdefault ( " key_source " , str ( getattr ( entry , " key_source " , " " ) or " " ) )
runtime_api_key = getattr ( entry , " runtime_api_key " , None )
if runtime_api_key :
payload [ " runtime_api_key " ] = runtime_api_key
base_url = getattr ( entry , " base_url " , None )
if base_url :
payload [ " base_url " ] = base_url
inference_base_url = getattr ( entry , " inference_base_url " , None )
if inference_base_url :
payload [ " inference_base_url " ] = inference_base_url
payloads . append ( payload )
return payloads
def _has_explicit_pool_credentials ( provider_id : str ) - > bool :
""" Return True when the credential pool has at least one non-ambient entry
for * provider_id * ( i . e . not a gh - cli / GITHUB_TOKEN auto - detect ) .
Reuses ` ` _CREDENTIAL_POOL_CACHE ` ` so that callers on hot paths ( provider
detection , model listing , live - model fetch ) don ' t pay the ~10s load_pool
cost more than once per TTL window .
"""
return bool ( _pool_entry_payloads ( provider_id ) )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_provider_models_invalidated_ts : dict [ str , float ] = { } # provider_id -> timestamp of last invalidation
# Disk-backed in-memory cache for get_available_models().
# Written to disk on every cache population so the cache survives server restarts.
# Invalidated (file deleted) whenever a provider is added/changed/removed or
# config.yaml changes. A TTL is still used as a fallback in case the invalidation
# signal is somehow missed, but the cache will always be warm after the first
# page load following a server start.
2026-04-26 01:38:06 +00:00
# Cache file lives inside STATE_DIR so each server instance (different
# HERMES_WEBUI_STATE_DIR / port) has its own file and test runs never
# pollute the production server's cache. Also works on macOS and Windows
# where /dev/shm does not exist.
2026-05-04 17:03:02 +00:00
def _current_webui_version ( ) - > str | None :
""" Lazy resolver for the WebUI version, used to stamp the disk cache (#1633).
` api . updates ` imports ` api . config ` at module - load time , so we cannot
` from api . updates import WEBUI_VERSION ` at the top of this module without a
circular import . Instead we resolve lazily on each cache load / save .
Returns the runtime version string ( e . g . ` ` v0 .50 .293 ` ` ) when api . updates
has been imported , or None if it isn ' t loaded yet (boot-time corner case
before the server has finished initializing ) . A None return is treated as
" do not stamp / do not validate " by the cache layer so cache reads / writes
that happen during early init still work — the next call after init will
stamp normally .
"""
try :
# Read attribute via dotted lookup so we don't add an import-time edge.
import sys as _sys
mod = _sys . modules . get ( ' api.updates ' )
if mod is None :
return None
v = getattr ( mod , ' WEBUI_VERSION ' , None )
return str ( v ) if v else None
except Exception :
return None
# Disk-cache schema version (#1633).
#
# Bumped any time the disk cache shape changes in a backward-incompatible way
# (e.g. new required field, renamed key). Independent of the WebUI version
# stamp — _webui_version forces a rebuild on every release; _schema_version
# guarantees that even if a future release accidentally reuses the same
# WebUI version string (or a debug build doesn't have a version), a structural
# change still invalidates the cache.
2026-05-05 15:33:44 +00:00
_MODELS_CACHE_SCHEMA_VERSION = 3
2026-05-04 17:03:02 +00:00
2026-04-26 01:38:06 +00:00
_models_cache_path = STATE_DIR / " models_cache.json "
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
2026-06-11 01:17:22 +00:00
def _get_models_cache_path ( ) - > Path :
""" Return the /api/models disk-cache path for the *active* profile (#3957).
WebUI profile switching is per - client / cookie scoped ( issue #798), but the
models disk cache used to be a single import - time ` ` STATE_DIR /
" models_cache.json " ` ` shared across every profile . The cache ' s
` ` _source_fingerprint ` ` is profile - specific ( it hashes the active profile ' s
config . yaml + auth . json ) , so a non - default profile rejected the shared
snapshot on every read and cold - rebuilt the catalog — the serial live
provider probes behind that cold build are what pushed ` ` / api / models ` ` ( and
the Settings → Providers panel ) past the 30 s frontend timeout .
Profile - key the filename so each profile keeps its own warm cache :
- default / root profile → ` ` models_cache . json ` ` ( unchanged path ; no
migration of the existing file )
- named profile ` ` < name > ` ` → ` ` models_cache . < name > . json ` `
The active profile is resolved per - request via ` ` get_active_profile_name ( ) ` `
( thread - local cookie context ) , falling back to the module - level default
path if the profiles module is unavailable ( very early boot / import cycle ) .
The named - profile path is derived from ` ` _models_cache_path ` ` ( the
module - level default ) , not from ` ` STATE_DIR ` ` directly , so the path stays
correct if the default is repointed ( e . g . tests monkeypatch
` ` _models_cache_path ` ` to an isolated tmp file ) .
"""
try :
from api . profiles import get_active_profile_name , _is_root_profile
name = ( get_active_profile_name ( ) or " " ) . strip ( )
if not name or _is_root_profile ( name ) :
return _models_cache_path
# Defensive filename sanitization: the cookie-derived profile name is
# already validated by _PROFILE_ID_RE at the request boundary, but keep
# the on-disk filename safe regardless of how the name was resolved.
safe = re . sub ( r " [^a-z0-9_-] " , " _ " , name . lower ( ) ) [ : 64 ]
if not safe :
return _models_cache_path
# Splice the profile into the default filename: models_cache.json →
# models_cache.<safe>.json, keeping the default's parent dir + suffix.
base = _models_cache_path
return base . with_name ( f " { base . stem } . { safe } { base . suffix } " )
except Exception :
return _models_cache_path
2026-05-05 15:33:44 +00:00
def _get_auth_store_path ( ) - > Path :
""" Return the auth.json path for the active Hermes profile. """
try :
from api . profiles import get_active_hermes_home as _gah
return _gah ( ) / " auth.json "
except ImportError :
2026-05-25 00:41:26 +00:00
return _DEFAULT_HERMES_HOME / " auth.json "
2026-05-05 15:33:44 +00:00
def _models_cache_file_fingerprint ( path : Path ) - > dict :
""" Return non-secret identity metadata for a cache dependency file.
The / api / models response depends on config . yaml ( model / provider defaults )
and auth . json ( active_provider + credential_pool ) . The cache only needs
cheap invalidation signals here , not file contents ; never include secrets .
"""
fingerprint = { " path " : str ( Path ( path ) . expanduser ( ) ) }
try :
st = Path ( path ) . stat ( )
except OSError :
fingerprint [ " missing " ] = True
return fingerprint
fingerprint [ " mtime_ns " ] = st . st_mtime_ns
fingerprint [ " size " ] = st . st_size
return fingerprint
2026-05-17 05:23:24 +00:00
def _models_cache_catalog_fingerprint ( ) - > dict :
""" Return non-secret model-catalog identity metadata for cache invalidation.
The / api / models payload is not only a function of user config / auth files .
It also depends on the provider / model catalog baked into this module and on
small local catalogs such as Codex ' s models_cache.json. Keep this cheap and
deterministic so a server restart after catalog changes does not keep
serving an otherwise - valid persisted models_cache . json until the 24 h TTL
expires ( #2443).
"""
catalog_payload = {
" provider_models " : _PROVIDER_MODELS ,
" provider_display " : _PROVIDER_DISPLAY ,
}
try :
encoded = json . dumps (
catalog_payload ,
sort_keys = True ,
separators = ( " , " , " : " ) ,
ensure_ascii = True ,
default = str ,
) . encode ( " utf-8 " )
provider_catalog_sha = hashlib . sha256 ( encoded ) . hexdigest ( )
except Exception :
provider_catalog_sha = " unavailable "
codex_home = Path ( os . getenv ( " CODEX_HOME " , " " ) . strip ( ) or ( HOME / " .codex " ) ) . expanduser ( )
return {
" provider_catalog_sha256 " : provider_catalog_sha ,
" codex_models_cache " : _models_cache_file_fingerprint ( codex_home / " models_cache.json " ) ,
}
fix(config): exclude credential-rotation fields from models-cache auth.json fingerprint
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.
2026-05-19 01:24:42 +00:00
# Credential-rotation fields inside auth.json that churn on a ~14-minute
# period (credential-pool / OAuth token refresh rewrites the whole file) but
# DO NOT change the set of available providers or models that /api/models
# returns. mtime/size-based fingerprinting (#1699's _models_cache_file_
# fingerprint) treats every one of these rewrites as a cache-invalidating
# change, so the 24h models cache is effectively dead — every few minutes a
# tab pays a full cold get_available_models() rebuild (see RCA t_d127953d /
# t_16551f61). We strip ONLY these known-inert fields and fingerprint the
# rest of auth.json by content, so token rotation no longer busts the cache.
#
# This is a DENY-list, not an allow-list, on purpose: a field we don't know
# about stays IN the fingerprint, so any genuine change to provider
# enablement / endpoint / api-base / model-allow (active_provider, a NEW
# credential_pool entry id, base_url, source, label, key_source, auth_type,
# priority, the providers{} block, …) still correctly invalidates the cache.
# The safety invariant is one-directional: excluding these fields can only
# ever make the fingerprint MORE stable, never make it miss a real
# provider/model-set change — because none of these fields feed
# detected_providers / the catalog in _build_available_models_uncached().
_AUTH_FINGERPRINT_VOLATILE_KEYS = frozenset ( {
# Secret material — rotates on refresh, never gates the provider/model set.
" access_token " ,
" refresh_token " ,
" id_token " ,
" api_key " ,
" secret " ,
2026-05-27 01:29:48 +00:00
" client_secret " , # rotation-only on purpose; not a model-cache differentiator
fix(config): exclude credential-rotation fields from models-cache auth.json fingerprint
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.
2026-05-19 01:24:42 +00:00
# Expiry / liveness — bumped every refresh, derived from the token above.
" expires_at " ,
" expires_at_ms " ,
" expires_in " ,
# Per-credential status/telemetry — churns on every request, not config.
" last_status " ,
" last_status_at " ,
" last_error_code " ,
" last_error_reason " ,
" last_error_message " ,
" last_error_reset_at " ,
" request_count " ,
# Whole-file save timestamp — rewritten on every _save_auth_store().
" updated_at " ,
} )
def _strip_volatile_auth_fields ( obj ) :
""" Recursively drop credential-rotation-only keys from an auth.json tree.
Pure structural transform ; never mutates the input . Any key NOT in the
deny - list is preserved verbatim so real provider / endpoint changes still
show through in the fingerprint .
"""
if isinstance ( obj , dict ) :
return {
k : _strip_volatile_auth_fields ( v )
for k , v in obj . items ( )
if k not in _AUTH_FINGERPRINT_VOLATILE_KEYS
}
if isinstance ( obj , list ) :
return [ _strip_volatile_auth_fields ( v ) for v in obj ]
return obj
def _auth_store_semantic_fingerprint ( path : Path ) - > dict :
""" Return a content fingerprint of auth.json that ignores token churn.
Unlike _models_cache_file_fingerprint ( ) ( mtime_ns + size ) , this hashes
the JSON content with the credential - rotation fields stripped , so the
~ 14 - min token - refresh rewrite of auth . json does NOT invalidate the 24 h
/ api / models cache . A change to anything that actually affects the
provider / model set ( active_provider , a new credential_pool entry , a
changed base_url / source / label / auth_type , the providers { } block , … )
still changes the hash and correctly busts the cache .
Failure modes are deliberately conservative — if the file is missing we
record that , and if it can ' t be read/parsed we fall back to the old
mtime / size fingerprint so behaviour is never * less * safe than #1699.
"""
p = Path ( path ) . expanduser ( )
fp : dict = { " path " : str ( p ) }
try :
st = p . stat ( )
except OSError :
fp [ " missing " ] = True
return fp
try :
raw = json . loads ( p . read_text ( encoding = " utf-8 " ) )
except Exception :
# Unreadable / corrupt / mid-write: fall back to the stat-based
# fingerprint. Strictly no less safe than the pre-fix behaviour
# (every write still invalidates) for this rare path only.
fp [ " mtime_ns " ] = st . st_mtime_ns
fp [ " size " ] = st . st_size
fp [ " semantic " ] = " unparsed-fallback "
return fp
stripped = _strip_volatile_auth_fields ( raw )
try :
encoded = json . dumps (
stripped ,
sort_keys = True ,
separators = ( " , " , " : " ) ,
ensure_ascii = True ,
default = str ,
) . encode ( " utf-8 " )
fp [ " semantic_sha256 " ] = hashlib . sha256 ( encoded ) . hexdigest ( )
except Exception :
fp [ " mtime_ns " ] = st . st_mtime_ns
fp [ " size " ] = st . st_size
fp [ " semantic " ] = " encode-fallback "
return fp
2026-05-05 15:33:44 +00:00
def _models_cache_source_fingerprint ( ) - > dict :
fix(config): exclude credential-rotation fields from models-cache auth.json fingerprint
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.
2026-05-19 01:24:42 +00:00
""" Return the current config/auth/catalog fingerprint for /api/models cache.
The auth . json axis uses a * content * fingerprint that excludes pure
credential - rotation fields ( see _auth_store_semantic_fingerprint ) : the
auth store is rewritten roughly every 14 minutes by token refresh , and
a stat - based ( mtime / size ) fingerprint made the 24 h cache churn on every
one of those rewrites ( RCA t_16551f61 ) . config . yaml keeps the cheap
mtime / size fingerprint because it is only rewritten on deliberate user
edits ( which can change anything ) and does not churn on a timer .
"""
2026-05-05 15:33:44 +00:00
return {
" config_yaml " : _models_cache_file_fingerprint ( _get_config_path ( ) ) ,
fix(config): exclude credential-rotation fields from models-cache auth.json fingerprint
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.
2026-05-19 01:24:42 +00:00
" auth_json " : _auth_store_semantic_fingerprint ( _get_auth_store_path ( ) ) ,
2026-05-17 05:23:24 +00:00
" catalog " : _models_cache_catalog_fingerprint ( ) ,
2026-05-05 15:33:44 +00:00
}
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
def _delete_models_cache_on_disk ( ) - > None :
try :
2026-06-11 01:17:22 +00:00
os . unlink ( str ( _get_models_cache_path ( ) ) )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
except OSError :
pass # already absent
2026-04-28 23:54:48 +00:00
def _is_valid_models_cache ( cache : object ) - > bool :
2026-05-04 17:03:02 +00:00
""" Return True when a cache payload has the full /api/models shape.
SHAPE - only check : validates structural correctness of an in - memory or
on - disk cache . Use _is_loadable_disk_cache ( ) for the strictness needed
when reading from disk ( it adds version - stamp invalidation per #1633).
Kept loose so in - memory cache writes ( which never touch disk and so don ' t
need version stamping ) can use this validator unchanged .
"""
2026-04-28 23:54:48 +00:00
if not isinstance ( cache , dict ) :
return False
2026-04-30 02:54:07 +00:00
if not { " active_provider " , " default_model " , " configured_model_badges " , " groups " } . issubset ( cache ) :
2026-04-28 23:54:48 +00:00
return False
active_provider = cache . get ( " active_provider " )
return (
( active_provider is None or isinstance ( active_provider , str ) )
and isinstance ( cache . get ( " default_model " ) , str )
2026-04-30 02:54:07 +00:00
and isinstance ( cache . get ( " configured_model_badges " ) , dict )
2026-04-28 23:54:48 +00:00
and isinstance ( cache . get ( " groups " ) , list )
)
2026-05-04 17:03:02 +00:00
def _is_loadable_disk_cache ( cache : object ) - > bool :
""" Return True when an on-disk cache is safe to use after a process boot.
Adds two checks on top of _is_valid_models_cache ( #1633):
1. ` ` _schema_version ` ` matches ` _MODELS_CACHE_SCHEMA_VERSION ` . A bumped
schema version unconditionally invalidates older cache files .
2. ` ` _webui_version ` ` matches the current runtime version . Forces a
rebuild after every release so users see picker - shape fixes
immediately , instead of waiting up to 24 hours for the TTL to expire .
If the runtime version cannot be resolved ( early - init edge case ) ,
skip this check rather than wedge the boot .
2026-05-04 17:13:08 +00:00
Note : ` ` _webui_version ` ` is a string equality check , not a semver compare —
two debug builds with the same ` WEBUI_VERSION ` string but different actual
code wouldn ' t invalidate via this axis. ``_schema_version`` is the
independent invalidation axis for breaking changes that lack a tag bump ;
bump it whenever the cache shape changes incompatibly .
2026-05-04 17:03:02 +00:00
"""
if not _is_valid_models_cache ( cache ) :
return False
if not isinstance ( cache , dict ) : # appease type-narrowing — already guarded above
return False
2026-05-04 17:13:08 +00:00
cached_schema = cache . get ( " _schema_version " )
if cached_schema != _MODELS_CACHE_SCHEMA_VERSION :
# DEBUG telemetry per stage-294 absorption: makes "why did my cache
# rebuild" investigations one log-grep away.
logger . debug (
" models cache rejected: schema= %r vs runtime= %r " ,
cached_schema , _MODELS_CACHE_SCHEMA_VERSION ,
)
2026-05-04 17:03:02 +00:00
return False
runtime_version = _current_webui_version ( )
if runtime_version is not None :
cached_version = cache . get ( " _webui_version " )
if not isinstance ( cached_version , str ) or cached_version != runtime_version :
2026-05-04 17:13:08 +00:00
logger . debug (
" models cache rejected: webui_version= %r vs runtime= %r " ,
cached_version , runtime_version ,
)
2026-05-04 17:03:02 +00:00
return False
2026-05-05 15:33:44 +00:00
cached_sources = cache . get ( " _source_fingerprint " )
runtime_sources = _models_cache_source_fingerprint ( )
if cached_sources != runtime_sources :
logger . debug (
" models cache rejected: source_fingerprint= %r vs runtime= %r " ,
cached_sources ,
runtime_sources ,
)
return False
2026-05-04 17:03:02 +00:00
return True
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
def _load_models_cache_from_disk ( ) - > dict | None :
2026-05-04 17:03:02 +00:00
""" Load /api/models cache from disk if it exists and has current metadata.
Adds the per - release version check from #1633: a cache stamped with a
different WebUI version is treated as missing , forcing a fresh rebuild
that picks up any picker - shape fixes shipped in the new release . The
returned dict is the SHAPE - only cache ( without the ` _webui_version ` /
` _schema_version ` stamps ) so callers don ' t have to know about the
on - disk metadata fields .
"""
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
try :
import json as _j
2026-04-29 04:00:27 +00:00
2026-06-11 01:56:58 +00:00
cache_path = _get_models_cache_path ( )
if not cache_path . exists ( ) :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
return None
2026-06-11 01:56:58 +00:00
with open ( cache_path , encoding = " utf-8 " ) as f :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
cache = _j . load ( f )
2026-05-04 17:03:02 +00:00
if not _is_loadable_disk_cache ( cache ) :
return None
# Strip the disk-only metadata before returning, so the in-memory
2026-06-20 19:25:51 +00:00
# cache shape stays exactly what the rest of the code expects. The
# disk save path does not persist `aliases`, so reconstruct them from
# current config to keep the /api/models.aliases contract intact (a
# disk-cache hit must not silently drop `/model <alias>` resolution).
2026-05-04 17:03:02 +00:00
return {
" active_provider " : cache [ " active_provider " ] ,
" default_model " : cache [ " default_model " ] ,
" configured_model_badges " : cache [ " configured_model_badges " ] ,
" groups " : cache [ " groups " ] ,
2026-06-20 19:25:51 +00:00
" aliases " : (
cache [ " aliases " ]
if isinstance ( cache . get ( " aliases " ) , dict )
else _model_aliases_from_config ( )
) ,
2026-05-04 17:03:02 +00:00
}
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
except Exception :
return None
2026-06-20 19:25:51 +00:00
def _model_aliases_from_config ( ) - > dict [ str , str ] :
""" Build the normalized model-alias map from current config.
Mirrors the alias construction used by the live and static catalog paths so
the ` / api / models . aliases ` contract is consistent across every catalog source
( live , static , and the stale - disk fallback , which can ' t read aliases from a
disk cache that never persisted them ) .
"""
try :
raw_aliases = cfg . get ( " model " , { } ) . get ( " aliases " , { } )
if isinstance ( raw_aliases , dict ) :
return {
str ( k ) . strip ( ) : str ( v ) . strip ( )
for k , v in raw_aliases . items ( )
if k and v
}
except Exception :
pass
return { }
2026-06-20 18:21:31 +00:00
def _load_stale_models_cache_from_disk ( ) - > dict | None :
""" Load a shape-valid stale /api/models disk cache for timeout fallback only.
The main cache loader enforces metadata stamps for a full cold - path cache hit .
This helper intentionally does not apply that stricter policy , so we can still
recover a useful fallback payload when the strict loader rejected cache because
2026-06-20 19:19:52 +00:00
metadata or fingerprint fields are stale . It DOES still enforce the schema
version : a cross - schema cache can have an incompatible groups / badge shape , so
serving it to the picker could surface a broken catalog — schema mismatch is a
hard reject even on the fallback path .
2026-06-20 18:21:31 +00:00
"""
try :
import json as _j
cache_path = _get_models_cache_path ( )
if not cache_path . exists ( ) :
return None
with open ( cache_path , encoding = " utf-8 " ) as f :
cache = _j . load ( f )
if not _is_valid_models_cache ( cache ) :
return None
2026-06-20 19:19:52 +00:00
if cache . get ( " _schema_version " ) != _MODELS_CACHE_SCHEMA_VERSION :
return None
2026-06-20 18:21:31 +00:00
aliases = cache . get ( " aliases " )
2026-06-20 19:25:51 +00:00
if not isinstance ( aliases , dict ) :
# The disk cache save path does not persist `aliases`, so a cache
# read back from disk lacks them. Defaulting to {} would silently
# break `/model <alias>` slash-command resolution (static/commands.js
# resolves slash aliases only from /api/models.aliases) for the
# duration of the over-budget stale fallback. Reconstruct from
# current config, mirroring the live/static catalog alias build.
aliases = _model_aliases_from_config ( )
2026-06-20 18:21:31 +00:00
return {
" active_provider " : cache [ " active_provider " ] ,
" default_model " : cache [ " default_model " ] ,
" configured_model_badges " : cache [ " configured_model_badges " ] ,
" groups " : cache [ " groups " ] ,
2026-06-20 19:25:51 +00:00
" aliases " : aliases ,
2026-06-20 18:21:31 +00:00
}
except Exception :
return None
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
def _save_models_cache_to_disk ( cache : dict ) - > None :
2026-05-04 17:03:02 +00:00
""" Save cache to disk so it survives server restarts.
Stamps the payload with ` _webui_version ` and ` _schema_version ` ( #1633) so
a subsequent process running a different WebUI version , or a future
release that bumps the schema , will treat the file as invalid and
rebuild from live provider data on its first / api / models call .
The version stamp is omitted ( not the literal None — the field is just
skipped ) when the runtime version cannot be resolved at the moment of
save , which would happen only in a very early boot path before
api . updates is loaded . _is_loadable_disk_cache treats a missing field as
a mismatch ( since runtime_version is non - None on every subsequent call ) ,
so this is safe — at worst we write one cache file that gets rejected
once on the next boot .
"""
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
try :
2026-04-28 23:54:48 +00:00
if not _is_valid_models_cache ( cache ) :
return
2026-05-04 17:03:02 +00:00
payload = {
" _schema_version " : _MODELS_CACHE_SCHEMA_VERSION ,
2026-05-05 15:33:44 +00:00
" _source_fingerprint " : _models_cache_source_fingerprint ( ) ,
2026-05-04 17:03:02 +00:00
" active_provider " : cache [ " active_provider " ] ,
" default_model " : cache [ " default_model " ] ,
" configured_model_badges " : cache [ " configured_model_badges " ] ,
" groups " : cache [ " groups " ] ,
}
runtime_version = _current_webui_version ( )
if runtime_version is not None :
payload [ " _webui_version " ] = runtime_version
2026-06-11 01:17:22 +00:00
cache_path = _get_models_cache_path ( )
tmp = str ( cache_path ) + f " . { os . getpid ( ) } .tmp "
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
with open ( tmp , " w " , encoding = " utf-8 " ) as f :
2026-05-04 17:03:02 +00:00
json . dump ( payload , f , indent = 2 )
2026-06-11 01:17:22 +00:00
os . rename ( tmp , str ( cache_path ) )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
except Exception :
pass # Non-fatal -- cache will rebuild on next call
2026-04-21 00:33:03 +00:00
2026-04-29 04:00:27 +00:00
def _get_fresh_memory_models_cache ( now : float ) - > dict | None :
""" Return a valid fresh in-memory /api/models cache, or clear stale shapes. """
2026-06-27 10:36:18 +00:00
global _available_models_cache , _available_models_cache_ts
global _available_models_live_rebuild_ts , _available_models_cache_source_fingerprint
2026-04-29 04:00:27 +00:00
if _available_models_cache is None :
return None
if ( now - _available_models_cache_ts ) > = _AVAILABLE_MODELS_CACHE_TTL :
return None
2026-05-05 15:33:44 +00:00
current_sources = _models_cache_source_fingerprint ( )
if _available_models_cache_source_fingerprint != current_sources :
logger . debug (
" models memory cache rejected: source_fingerprint= %r vs runtime= %r " ,
_available_models_cache_source_fingerprint ,
current_sources ,
)
_available_models_cache = None
_available_models_cache_ts = 0.0
2026-06-27 10:36:18 +00:00
_available_models_live_rebuild_ts = 0.0
2026-05-05 15:33:44 +00:00
_available_models_cache_source_fingerprint = None
return None
2026-04-29 04:00:27 +00:00
if _is_valid_models_cache ( _available_models_cache ) :
return copy . deepcopy ( _available_models_cache )
_available_models_cache = None
_available_models_cache_ts = 0.0
2026-06-27 10:36:18 +00:00
_available_models_live_rebuild_ts = 0.0
2026-05-05 15:33:44 +00:00
_available_models_cache_source_fingerprint = None
2026-04-29 04:00:27 +00:00
return None
2026-04-21 00:33:03 +00:00
def invalidate_models_cache ( ) :
""" Force the TTL cache for get_available_models() to be cleared.
Call this after modifying config . cfg in - memory ( e . g . in tests ) so
the next call to get_available_models ( ) picks up the changes rather
than returning a stale cached result .
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
Also deletes the on - disk cache so that a subsequent cold build does
not immediately reload a stale disk snapshot and skip the fresh build .
This is essential for test isolation : without the disk delete , tests
that call invalidate_models_cache ( ) still get back the previous test ' s
2026-04-26 01:38:06 +00:00
result from the disk cache because the disk hit is checked before the memory
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
cache rebuild runs .
2026-04-21 00:33:03 +00:00
"""
2026-06-27 10:36:18 +00:00
global _cache_build_in_progress , _available_models_cache , _available_models_cache_ts
global _available_models_live_rebuild_ts , _available_models_cache_source_fingerprint , _cache_build_cv
2026-04-21 00:33:03 +00:00
with _available_models_cache_lock :
_available_models_cache = None
_available_models_cache_ts = 0.0
2026-06-27 10:36:18 +00:00
_available_models_live_rebuild_ts = 0.0
2026-05-05 15:33:44 +00:00
_available_models_cache_source_fingerprint = None
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_cache_build_in_progress = False
_cache_build_cv . notify_all ( )
2026-06-16 18:09:01 +00:00
# Clear the credential pool cache too (all profiles). Without this,
# tests (and live provider key edits) see a stale CredentialPool from a
# prior auth_store payload — the test_credential_pool_providers suite was
# hitting this directly. A full reset is intentionally profile-wide.
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_CREDENTIAL_POOL_CACHE . clear ( )
# Also delete the disk cache so the next cold build starts fresh.
# Disk delete is outside the lock — file I/O shouldn't block other readers.
_delete_models_cache_on_disk ( )
2026-06-05 18:35:26 +00:00
try :
from api . plugin_providers import invalidate_plugin_model_provider_cache
invalidate_plugin_model_provider_cache ( )
except Exception :
pass
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
2026-05-03 18:32:53 +00:00
def invalidate_credential_pool_cache ( provider_id : str ) :
""" Invalidate the credential pool cache for a specific provider.
Used by the streaming layer ' s credential self-heal logic (#1401) to
force a fresh credential pool load after re - reading auth . json .
"""
global _CREDENTIAL_POOL_CACHE
with _available_models_cache_lock :
2026-06-16 18:09:01 +00:00
_cp_tag = _credential_pool_profile_tag ( )
_CREDENTIAL_POOL_CACHE . pop ( ( _cp_tag , provider_id ) , None )
_CREDENTIAL_POOL_CACHE . pop ( ( _cp_tag , _resolve_provider_alias ( provider_id ) ) , None )
2026-05-13 03:00:24 +00:00
try :
2026-05-13 03:16:34 +00:00
# api.providers imports from api.config; keep this lazy to avoid
# import-cycle/module-initialization issues.
2026-05-13 03:00:24 +00:00
from api . providers import invalidate_account_usage_status_cache
invalidate_account_usage_status_cache ( provider_id )
invalidate_account_usage_status_cache ( _resolve_provider_alias ( provider_id ) )
except Exception :
logger . debug ( " Failed to invalidate account usage status cache " , exc_info = True )
2026-05-03 18:32:53 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
def invalidate_provider_models_cache ( provider_id : str ) :
""" Invalidate cached models for a single provider.
Also invalidates the full cache so that the next get_available_models ( )
call rebuilds all groups cleanly ( the rebuilt provider is merged with any
other cached groups from the 24 h TTL window ) . After the next
get_available_models ( ) call , _provider_models_invalidated_ts [ provider_id ]
is cleared so the provider ' s fresh models are used.
Args :
provider_id : canonical provider id ( e . g . ' openai ' , ' anthropic ' , ' custom:my-key ' )
"""
2026-06-27 10:36:18 +00:00
global _available_models_cache , _available_models_cache_ts
global _available_models_live_rebuild_ts , _available_models_cache_source_fingerprint , _CREDENTIAL_POOL_CACHE
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
with _available_models_cache_lock :
_available_models_cache = None
_available_models_cache_ts = 0.0
2026-06-27 10:36:18 +00:00
_available_models_live_rebuild_ts = 0.0
2026-05-05 15:33:44 +00:00
_available_models_cache_source_fingerprint = None
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_provider_models_invalidated_ts [ provider_id ] = time . time ( )
# Also evict the credential pool so the next cold path re-loads it.
# Must evict both the original key and its canonical form (load_pool
2026-06-16 18:09:01 +00:00
# may be called with either, and both paths cache under their own key),
# scoped to the active profile's cache key.
_cp_tag = _credential_pool_profile_tag ( )
_CREDENTIAL_POOL_CACHE . pop ( ( _cp_tag , provider_id ) , None )
_CREDENTIAL_POOL_CACHE . pop ( ( _cp_tag , _resolve_provider_alias ( provider_id ) ) , None )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_delete_models_cache_on_disk ( )
2026-04-21 00:33:03 +00:00
2026-04-23 21:41:06 +00:00
def _get_label_for_model ( model_id : str , existing_groups : list ) - > str :
""" Return a human-friendly label for *model_id*.
Resolution order :
1. If the model already appears in * existing_groups * with a label , use it .
2. Strip @provider : prefix and namespace prefix , then title - case .
This ensures the injected default model entry in the dropdown always shows
the same label as the live - fetched or static - catalog version , rather than
the raw lowercase ID string ( #909).
"""
# Strip @provider: prefix for lookup
lookup_id = model_id
if lookup_id . startswith ( " @ " ) and " : " in lookup_id :
lookup_id = lookup_id . split ( " : " , 1 ) [ 1 ]
2026-06-02 21:04:02 +00:00
# Check existing groups for a matching label.
# Skip slash stripping for URI-scheme IDs (e.g. gpt://folder/model) (#3429).
_has_scheme = lambda s : " :// " in s
_norm = lambda s : ( s . split ( " / " , 1 ) [ - 1 ] if ( " / " in s and not _has_scheme ( s ) ) else s ) . replace ( " - " , " . " ) . lower ( )
2026-04-23 21:41:06 +00:00
norm_lookup = _norm ( lookup_id )
for g in existing_groups :
for m in g . get ( " models " , [ ] ) :
if m . get ( " label " ) and _norm ( str ( m . get ( " id " , " " ) ) ) == norm_lookup :
return m [ " label " ]
fix: model picker snaps to wrong model with multi-slash IDs (#3360)
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)
2026-06-01 20:46:13 +00:00
# Fall back: strip only the first slash-segment (provider prefix),
# preserving vendor hierarchy for multi-slash IDs (#3360).
2026-06-02 21:04:02 +00:00
# Skip for URI-scheme IDs whose slashes are path separators (#3429).
bare = lookup_id . split ( " / " , 1 ) [ 1 ] if ( " / " in lookup_id and not _has_scheme ( lookup_id ) ) else lookup_id
2026-04-23 21:41:06 +00:00
return " " . join (
w . upper ( ) if ( len ( w ) < = 3 and w . replace ( " . " , " " ) . isalnum ( ) and not w . isdigit ( ) ) else w . capitalize ( )
for w in bare . replace ( " _ " , " - " ) . split ( " - " )
)
2026-05-05 23:48:56 +00:00
def _read_live_provider_model_ids ( provider_id : str ) - > list [ str ] :
""" Return live model IDs from Hermes CLI for a provider, or [] on failure.
WebUI ' s static ``_PROVIDER_MODELS`` table is only a fallback. The agent CLI
owns the provider registry and catalog - discovery logic , so ordinary picker
groups should ask ` ` hermes_cli . models . provider_model_ids ( ) ` ` first ( #1240).
Provider aliases are tried as a secondary lookup because WebUI keeps a few
display - facing IDs ( for example ` ` google ` ` / ` ` x - ai ` ` ) that Hermes CLI may
normalize internally .
"""
pid = str ( provider_id or " " ) . strip ( )
if not pid :
return [ ]
try :
from hermes_cli . models import provider_model_ids as _provider_model_ids
except Exception :
return [ ]
candidates = [ pid ]
try :
alias = _resolve_provider_alias ( pid )
except Exception :
alias = " "
if alias and alias not in candidates :
candidates . append ( alias )
seen : set [ str ] = set ( )
for candidate in candidates :
try :
live_ids = _provider_model_ids ( candidate ) or [ ]
except Exception :
logger . debug ( " Failed to load %s models from hermes_cli " , candidate )
continue
result : list [ str ] = [ ]
for mid in live_ids :
mid_s = str ( mid or " " ) . strip ( )
if mid_s and mid_s not in seen :
seen . add ( mid_s )
result . append ( mid_s )
if result :
return result
return [ ]
def _models_from_live_provider_ids ( provider_id : str , live_ids : list [ str ] ) - > list [ dict ] :
""" Convert Hermes CLI model ids into WebUI picker model entries. """
formatter = _format_ollama_label if provider_id in ( " ollama " , " ollama-cloud " ) else None
models : list [ dict ] = [ ]
seen : set [ str ] = set ( )
for mid in live_ids :
mid_s = str ( mid or " " ) . strip ( )
if not mid_s or mid_s in seen :
continue
seen . add ( mid_s )
label = formatter ( mid_s ) if formatter else _get_label_for_model ( mid_s , [ ] )
models . append ( { " id " : mid_s , " label " : label } )
return models
2026-07-01 00:16:26 +00:00
def _moa_preset_models_from_config ( config_obj : dict | None = None ) - > list [ dict ] :
""" Return enabled MoA presets from local config as picker model entries. """
source = config_obj if isinstance ( config_obj , dict ) else cfg
moa_cfg = source . get ( " moa " ) if isinstance ( source , dict ) else None
if not isinstance ( moa_cfg , dict ) or not bool ( moa_cfg . get ( " enabled " , True ) ) :
return [ ]
presets = moa_cfg . get ( " presets " )
if not isinstance ( presets , dict ) :
return [ ]
models : list [ dict ] = [ ]
seen : set [ str ] = set ( )
for name , preset_cfg in presets . items ( ) :
preset_name = str ( name or " " ) . strip ( )
if not preset_name or preset_name in seen :
continue
if isinstance ( preset_cfg , dict ) and preset_cfg . get ( " enabled " ) is False :
continue
seen . add ( preset_name )
models . append ( { " id " : preset_name , " label " : preset_name } )
return models
2026-05-05 06:10:36 +00:00
def _read_visible_codex_cache_model_ids ( ) - > list [ str ] :
""" Return visible model slugs from Codex ' s local models_cache.json.
The agent ' s provider_model_ids( ' openai - codex ' ) intentionally filters IDs
with ` ` supported_in_api : false ` ` . Codex CLI still lists some of those models
in its picker ( notably ` ` gpt - 5.3 - codex - spark ` ` from #1680), so the WebUI
merges this visible local catalog to stay in sync with Codex itself .
"""
codex_home = Path ( os . getenv ( " CODEX_HOME " , " " ) . strip ( ) or ( HOME / " .codex " ) ) . expanduser ( )
cache_path = codex_home / " models_cache.json "
try :
payload = json . loads ( cache_path . read_text ( encoding = " utf-8 " ) )
except Exception :
return [ ]
entries = payload . get ( " models " ) if isinstance ( payload , dict ) else None
if not isinstance ( entries , list ) :
return [ ]
sortable : list [ tuple [ int , str ] ] = [ ]
for item in entries :
if not isinstance ( item , dict ) :
continue
slug = item . get ( " slug " )
if not isinstance ( slug , str ) or not slug . strip ( ) :
continue
visibility = item . get ( " visibility " , " " )
if isinstance ( visibility , str ) and visibility . strip ( ) . lower ( ) in ( " hide " , " hidden " ) :
continue
priority = item . get ( " priority " )
rank = int ( priority ) if isinstance ( priority , ( int , float ) ) else 10_000
sortable . append ( ( rank , slug . strip ( ) ) )
sortable . sort ( key = lambda item : ( item [ 0 ] , item [ 1 ] ) )
ordered : list [ str ] = [ ]
for _ , slug in sortable :
if slug not in ordered :
ordered . append ( slug )
return ordered
2026-06-23 16:18:44 +00:00
def get_available_models ( * , prefer_cache : bool = False , force_refresh : bool = False ) - > dict :
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
"""
Return available models grouped by provider .
Discovery order :
1. Read config . yaml ' model ' section for active provider info
2. Check for known API keys in env or ~ / . hermes / . env
2026-04-02 06:32:49 +00:00
3. Fetch models from custom endpoint if base_url is configured
4. Fall back to hardcoded model list ( OpenRouter - style )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
Returns : {
' active_provider ' : str | None ,
' default_model ' : str ,
' groups ' : [ { ' provider ' : str , ' models ' : [ { ' id ' : str , ' label ' : str } ] } ]
}
2026-06-09 05:36:18 +00:00
` ` prefer_cache = True ` ` resolves WITHOUT ever triggering a live provider
probe : it serves the warm in - memory cache , then the last - known on - disk
cache , and only as a last resort a network - free minimal catalog
( config / auth derived ) . It NEVER does the per - provider live rebuild ( the
Copilot token - exchange HTTPS call et al . ) . This is the path a
server - initiated wakeup turn ( Option Z ) takes so a cold catalog can never
block the wakeup chat / start on a flaky network . A normal human request
leaves this False and keeps the full live - discovery behaviour .
2026-06-23 16:18:44 +00:00
` ` force_refresh = True ` ` is an internal escape hatch for bounded freshness
checks that need a real live rebuild while preserving the default cache
contract for every existing caller .
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
"""
2026-06-27 10:36:18 +00:00
global _cache_build_in_progress , _available_models_cache , _available_models_cache_ts
global _available_models_live_rebuild_ts , _available_models_cache_source_fingerprint , _cache_build_cv
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# Config mtime check — must come before any config reads.
# (Test #585 verifies _current_mtime appears before active_provider = None)
try :
2026-05-06 01:39:11 +00:00
_current_path = _get_config_path ( )
_current_mtime = _current_path . stat ( ) . st_mtime
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
except OSError :
2026-05-06 01:39:11 +00:00
_current_path = _get_config_path ( )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_current_mtime = 0.0
2026-05-06 01:39:11 +00:00
if (
( _current_mtime != _cfg_mtime or _current_path != _cfg_path )
and not _cfg_has_in_memory_overrides ( )
) :
2026-06-29 20:20:07 +00:00
reload_config_if_stale ( )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# ── COLD PATH helper ─────────────────────────────────────────────────────
# Extracted so it runs inside _available_models_cache_lock (RLock) to
# prevent thundering-herd: only one thread rebuilds while others wait.
def _build_available_models_uncached ( ) - > dict :
active_provider = None
default_model = get_effective_default_model ( cfg )
groups = [ ]
2026-04-30 02:54:07 +00:00
def _norm_model_id ( model_id : str ) - > str :
s = str ( model_id or " " ) . strip ( ) . lower ( )
2026-06-14 23:27:39 +00:00
stripped_at_provider = False
2026-05-02 17:03:25 +00:00
# Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> GLM-5).
# Defensive: if the last segment is empty (trailing colon, malformed
# config), keep the original to avoid collapsing distinct IDs to ''.
2026-04-30 02:54:07 +00:00
if s . startswith ( " @ " ) and " : " in s :
2026-06-14 23:27:39 +00:00
# Strip @provider: prefix, preserving remaining hierarchy including
# colon-suffixed model IDs like provider/model:free (#3959).
colon_idx = s . index ( " : " , 1 )
candidate = s [ colon_idx + 1 : ]
stripped_at_provider = bool ( candidate )
s = candidate or s
2026-06-02 21:04:02 +00:00
# Skip slash-based stripping for URI-scheme IDs (e.g.
# gpt://folder/model/latest) whose slashes are path separators,
# not provider delimiters (#3429).
if " :// " not in s :
2026-06-14 23:27:39 +00:00
if (
not stripped_at_provider
and " / " in s
and " : " in s
and s . index ( " : " ) < s . index ( " / " )
) :
s = s [ s . index ( " / " ) + 1 : ] or s
2026-06-02 21:04:02 +00:00
# Strip only the first slash-segment (provider prefix), preserving
# any remaining vendor hierarchy. Using parts[-1] here previously
# discarded ALL segments except the last, collapsing distinct
# multi-slash IDs like 'vendor_a/deepseek-v4-pro' and
# 'vendor_b/deepseek/deepseek-v4-pro' to the same key (#3360).
if " / " in s :
stripped = s . split ( " / " , 1 ) [ 1 ]
s = stripped or s
2026-04-30 02:54:07 +00:00
return s . replace ( " - " , " . " )
def _build_configured_model_badges ( ) - > dict [ str , dict [ str , str ] ] :
configured_entries : list [ dict [ str , str ] ] = [ ]
if active_provider and default_model :
configured_entries . append (
{
" provider " : active_provider ,
" model " : default_model ,
" role " : " primary " ,
" label " : " Primary " ,
}
)
fallback_cfg = cfg . get ( " fallback_providers " , [ ] )
if isinstance ( fallback_cfg , list ) :
for idx , entry in enumerate ( fallback_cfg , start = 1 ) :
if not isinstance ( entry , dict ) :
continue
provider = _resolve_provider_alias ( entry . get ( " provider " ) )
model = str ( entry . get ( " model " ) or " " ) . strip ( )
if not provider or not model :
continue
configured_entries . append (
{
" provider " : provider ,
" model " : model ,
" role " : " fallback " ,
" label " : f " Fallback { idx } " ,
}
)
option_ids = [ m . get ( " id " , " " ) for g in groups for m in g . get ( " models " , [ ] ) if m . get ( " id " ) ]
option_lookup = { str ( opt_id ) : str ( opt_id ) for opt_id in option_ids }
2026-04-30 15:24:35 +00:00
option_provider_lookup = {
str ( m . get ( " id " ) ) : str ( g . get ( " provider_id " ) or " " )
for g in groups
for m in g . get ( " models " , [ ] )
if m . get ( " id " )
}
2026-04-30 02:54:07 +00:00
norm_lookup : dict [ str , list [ str ] ] = { }
for opt_id in option_ids :
norm_lookup . setdefault ( _norm_model_id ( opt_id ) , [ ] ) . append ( opt_id )
badges : dict [ str , dict [ str , str ] ] = { }
for entry in configured_entries :
provider = entry [ " provider " ]
model = entry [ " model " ]
raw_candidates = [ ]
for candidate in (
model ,
f " { provider } / { model } " ,
f " @ { provider } : { model } " ,
) :
if candidate and candidate not in raw_candidates :
raw_candidates . append ( candidate )
match_id = None
2026-04-30 15:24:35 +00:00
exact_match = next ( ( option_lookup [ c ] for c in raw_candidates if c in option_lookup ) , None )
2026-04-30 02:54:07 +00:00
for candidate in raw_candidates :
2026-04-30 15:24:35 +00:00
if candidate in option_lookup and option_provider_lookup . get ( candidate ) == provider :
2026-04-30 02:54:07 +00:00
match_id = option_lookup [ candidate ]
break
if match_id is None :
for candidate in raw_candidates :
normalized = _norm_model_id ( candidate )
matches = norm_lookup . get ( normalized , [ ] )
if not matches :
continue
provider_match = next (
2026-04-30 15:24:35 +00:00
( m for m in matches if option_provider_lookup . get ( m ) == provider ) ,
2026-04-30 02:54:07 +00:00
None ,
)
2026-04-30 15:24:35 +00:00
match_id = provider_match or exact_match or matches [ 0 ]
2026-04-30 02:54:07 +00:00
if match_id :
break
badge_payload = { " role " : entry [ " role " ] , " label " : entry [ " label " ] , " provider " : provider }
for candidate in raw_candidates :
2026-04-30 15:24:35 +00:00
candidate_provider = option_provider_lookup . get ( candidate )
if candidate_provider and candidate_provider != provider :
continue
2026-04-30 02:54:07 +00:00
badges [ candidate ] = badge_payload
if match_id :
badges [ match_id ] = badge_payload
return badges
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# 1. Read config.yaml model section
cfg_base_url = " " # must be defined before conditional blocks (#117)
model_cfg = cfg . get ( " model " , { } )
cfg_base_url = " "
if isinstance ( model_cfg , str ) :
pass # default_model already set by get_effective_default_model
elif isinstance ( model_cfg , dict ) :
active_provider = model_cfg . get ( " provider " )
cfg_default = model_cfg . get ( " default " , " " )
cfg_base_url = model_cfg . get ( " base_url " , " " )
if cfg_default :
default_model = cfg_default
2026-05-07 13:40:23 +00:00
# Normalize active_provider to its canonical key. Named custom
# providers are first-class provider ids in WebUI routing; accept the
# user-facing name from config.yaml (``provider: ollama-local``) and
# route it through the same ``custom:<name>`` slug the picker emits.
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if active_provider :
2026-05-07 13:40:23 +00:00
active_provider = _resolve_configured_provider_id (
active_provider ,
cfg ,
base_url = cfg_base_url ,
)
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# 2. Read auth store (active_provider fallback + credential_pool inspection)
auth_store = { }
2026-05-05 15:33:44 +00:00
auth_store_path = _get_auth_store_path ( )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if auth_store_path . exists ( ) :
try :
import json as _j
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
auth_store = _j . loads ( auth_store_path . read_text ( encoding = " utf-8 " ) )
if not active_provider :
2026-05-07 13:40:23 +00:00
active_provider = _resolve_configured_provider_id (
auth_store . get ( " active_provider " ) ,
cfg ,
base_url = cfg_base_url ,
)
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
except Exception :
logger . debug ( " Failed to load auth store from %s " , auth_store_path )
2026-04-22 00:24:54 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# 3. Detect available providers.
detected_providers = set ( )
if active_provider :
detected_providers . add ( active_provider )
2026-04-12 07:11:41 +00:00
2026-04-22 20:18:02 +00:00
try :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_pool = auth_store . get ( " credential_pool " , { } ) if isinstance ( auth_store , dict ) else { }
if isinstance ( _pool , dict ) and _pool :
try :
from agent . credential_pool import load_pool as _load_pool
for _pid in list ( _pool . keys ( ) ) :
try :
_canonical_pid = _resolve_provider_alias ( str ( _pid ) )
2026-06-16 18:09:01 +00:00
# Check credential pool cache first (profile-scoped key
# so a pool loaded under another profile can't leak in).
_ck = ( _credential_pool_profile_tag ( ) , _pid )
_cached = _CREDENTIAL_POOL_CACHE . get ( _ck )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if _cached is not None :
_cp_ts , _cp_pool = _cached
if ( time . time ( ) - _cp_ts ) < 86400.0 :
_all_entries = _cp_pool . entries ( )
else :
_lp_t0 = time . monotonic ( )
_cp_pool = _load_pool ( _pid )
2026-06-16 18:09:01 +00:00
_CREDENTIAL_POOL_CACHE [ _ck ] = ( time . time ( ) , _cp_pool )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_all_entries = _cp_pool . entries ( )
else :
_lp_t0 = time . monotonic ( )
_cp_pool = _load_pool ( _pid )
2026-06-16 18:09:01 +00:00
_CREDENTIAL_POOL_CACHE [ _ck ] = ( time . time ( ) , _cp_pool )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_all_entries = _cp_pool . entries ( )
_explicit = [
e for e in _all_entries
if not _is_ambient_gh_cli_entry (
str ( getattr ( e , " source " , " " ) or " " ) ,
str ( getattr ( e , " label " , " " ) or " " ) ,
str ( getattr ( e , " key_source " , " " ) or " " ) ,
)
]
2026-06-16 21:45:43 +00:00
if _explicit and _is_known_model_provider ( _canonical_pid ) :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
detected_providers . add ( _canonical_pid )
except Exception :
logger . debug ( " credential_pool.load_pool( %s ) failed " , _pid )
except ImportError :
for _pid , _entries in _pool . items ( ) :
if not isinstance ( _entries , list ) or len ( _entries ) == 0 :
continue
_has_explicit_cred = any (
isinstance ( _entry , dict )
and not _is_ambient_gh_cli_entry (
str ( _entry . get ( " source " , " " ) or " " ) ,
str ( _entry . get ( " label " , " " ) or " " ) ,
str ( _entry . get ( " key_source " , " " ) or " " ) ,
2026-04-22 20:18:02 +00:00
)
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
for _entry in _entries
2026-04-22 20:18:02 +00:00
)
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if _has_explicit_cred :
2026-06-16 21:45:43 +00:00
_canonical_pid = _resolve_provider_alias ( str ( _pid ) )
if _is_known_model_provider ( _canonical_pid ) :
detected_providers . add ( _canonical_pid )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
except Exception :
logger . debug ( " Failed to inspect credential_pool from auth store " )
2026-04-22 20:18:02 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
all_env : dict = { }
2026-04-06 21:29:06 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_hermes_auth_used = False
try :
from hermes_cli . models import list_available_providers as _lap
from hermes_cli . auth import get_auth_status as _gas
2026-04-12 07:11:41 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
for _p in _lap ( ) :
if not _p . get ( " authenticated " ) :
2026-04-06 21:35:30 +00:00
continue
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
try :
_src = _gas ( _p [ " id " ] ) . get ( " key_source " , " " )
if _src == " gh auth token " :
continue
except Exception :
logger . debug ( " Failed to get key source for provider %s " , _p . get ( " id " , " unknown " ) )
detected_providers . add ( _p [ " id " ] )
_hermes_auth_used = True
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
# Belt-and-braces: list_available_providers() is the primary signal
# for OAuth providers, but its `authenticated` field can disagree
# with `get_auth_status(<id>).logged_in` on some hermes_cli versions
# (the two fields are computed via different code paths). When the
# disagreement happens for Nous Portal, the Settings → Providers
# card renders the live catalog (because api/providers.py iterates
# all OAuth providers regardless of authentication state) but the
# picker dropdown comes up empty — a confusing asymmetry reported
# in #1567. Add Nous explicitly when get_auth_status agrees so the
# picker stays in sync with the providers card.
try :
if _gas ( " nous " ) . get ( " logged_in " ) :
detected_providers . add ( " nous " )
except Exception :
logger . debug ( " Failed to check Nous Portal auth status " )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
except Exception :
logger . debug ( " Failed to detect auth providers from hermes " )
2026-04-06 21:29:06 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if not _hermes_auth_used :
try :
from api . profiles import get_active_hermes_home as _gah2
2026-04-12 07:11:41 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
hermes_env_path = _gah2 ( ) / " .env "
except ImportError :
2026-05-25 00:41:26 +00:00
hermes_env_path = _DEFAULT_HERMES_HOME / " .env "
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
env_keys = { }
if hermes_env_path . exists ( ) :
try :
for line in hermes_env_path . read_text ( encoding = " utf-8 " ) . splitlines ( ) :
line = line . strip ( )
if line and not line . startswith ( " # " ) and " = " in line :
k , v = line . split ( " = " , 1 )
env_keys [ k . strip ( ) ] = v . strip ( ) . strip ( ' " ' ) . strip ( " ' " )
except Exception :
logger . debug ( " Failed to parse hermes env file " )
all_env = { * * env_keys }
2026-06-26 14:19:58 +00:00
_anthropic_env_vars = _get_anthropic_fallback_env_vars ( )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
for k in (
2026-06-26 14:19:58 +00:00
* _anthropic_env_vars ,
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
" OPENAI_API_KEY " ,
" OPENROUTER_API_KEY " ,
" GOOGLE_API_KEY " ,
" GEMINI_API_KEY " ,
" GLM_API_KEY " ,
" KIMI_API_KEY " ,
" DEEPSEEK_API_KEY " ,
2026-05-10 23:33:52 +00:00
" XIAOMI_API_KEY " ,
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
" OPENCODE_ZEN_API_KEY " ,
" OPENCODE_GO_API_KEY " ,
2026-05-29 16:24:20 +00:00
" OPENCODE_API_KEY " ,
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
" MINIMAX_API_KEY " ,
" MINIMAX_CN_API_KEY " ,
v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy (#1127)
* fix(#604): model picker shows all configured providers
Two fixes to ensure the model picker surface every provider a user has
configured:
1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
(→ mistralai). Previously these providers were only detectable via
hermes auth or credential pool, not via environment variables.
2. Added config.yaml providers section scanning. Users who configure
providers in config.yaml (e.g. providers.anthropic.api_key) without
setting the corresponding env var will now see those providers in the
model picker. Only providers with known model catalogs are added.
- Added 12 regression tests
* fix(#1112): allow Google Fonts in CSP style-src and font-src
Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.
- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass
* fix(#1118): retry api() calls on network errors after long idle
After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.
- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests
* feat(#1116): composer placeholder reflects active profile name
When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.
- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests
* feat(#1097): drag and drop workspace files into chat composer
Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.
- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
inserts @path with smart spacing and cursor positioning
- Added 9 regression tests
* fix(#1096): copy buttons work — add clipboard-write Permissions-Policy
Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.
- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
to execCommand('copy'). _fallbackCopy extracted as separate function
with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests
* chore: CHANGELOG for v0.50.223
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 22:29:02 +00:00
" XAI_API_KEY " ,
" MISTRAL_API_KEY " ,
2026-05-23 07:56:23 +00:00
" AWS_ACCESS_KEY_ID " ,
" AWS_SECRET_ACCESS_KEY " ,
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
) :
2026-06-26 15:44:15 +00:00
val = _thread_local_env_value ( k ) . strip ( )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if val :
all_env [ k ] = val
2026-06-26 14:19:58 +00:00
if any ( all_env . get ( env_var ) for env_var in _anthropic_env_vars ) :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
detected_providers . add ( " anthropic " )
if all_env . get ( " OPENAI_API_KEY " ) :
2026-06-03 01:08:01 +00:00
# hermes-agent registers its OPENAI_API_KEY/OPENAI_BASE_URL provider
# under the slug `openai-api` (there is no bare `openai` in the agent
# registry — only `openai-api` and `openai-codex`). Detecting `openai`
# here would emit `@openai:` picker entries the agent can't resolve on
# the send path, so detect `openai-api` to match the registry (#3443).
detected_providers . add ( " openai-api " )
2026-04-28 01:40:13 +00:00
# openai-codex uses ChatGPT OAuth (not OPENAI_API_KEY) for its default endpoint.
# Detecting it here lets users who have both credentials configured find it in the
# picker without a manual config.yaml edit. Users without Codex OAuth will see
# picker entries but hit auth errors at inference time (#1189 known limitation).
detected_providers . add ( " openai-codex " )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if all_env . get ( " OPENROUTER_API_KEY " ) :
detected_providers . add ( " openrouter " )
if all_env . get ( " GOOGLE_API_KEY " ) :
detected_providers . add ( " google " )
if all_env . get ( " GEMINI_API_KEY " ) :
detected_providers . add ( " gemini " )
if all_env . get ( " GLM_API_KEY " ) :
detected_providers . add ( " zai " )
if all_env . get ( " KIMI_API_KEY " ) :
detected_providers . add ( " kimi-coding " )
2026-04-29 07:50:32 +00:00
if all_env . get ( " MINIMAX_API_KEY " ) :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
detected_providers . add ( " minimax " )
2026-04-29 07:50:32 +00:00
if all_env . get ( " MINIMAX_CN_API_KEY " ) :
detected_providers . add ( " minimax-cn " )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if all_env . get ( " DEEPSEEK_API_KEY " ) :
detected_providers . add ( " deepseek " )
2026-05-10 23:33:52 +00:00
if all_env . get ( " XIAOMI_API_KEY " ) :
detected_providers . add ( " xiaomi " )
v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy (#1127)
* fix(#604): model picker shows all configured providers
Two fixes to ensure the model picker surface every provider a user has
configured:
1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
(→ mistralai). Previously these providers were only detectable via
hermes auth or credential pool, not via environment variables.
2. Added config.yaml providers section scanning. Users who configure
providers in config.yaml (e.g. providers.anthropic.api_key) without
setting the corresponding env var will now see those providers in the
model picker. Only providers with known model catalogs are added.
- Added 12 regression tests
* fix(#1112): allow Google Fonts in CSP style-src and font-src
Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.
- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass
* fix(#1118): retry api() calls on network errors after long idle
After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.
- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests
* feat(#1116): composer placeholder reflects active profile name
When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.
- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests
* feat(#1097): drag and drop workspace files into chat composer
Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.
- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
inserts @path with smart spacing and cursor positioning
- Added 9 regression tests
* fix(#1096): copy buttons work — add clipboard-write Permissions-Policy
Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.
- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
to execCommand('copy'). _fallbackCopy extracted as separate function
with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests
* chore: CHANGELOG for v0.50.223
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 22:29:02 +00:00
if all_env . get ( " XAI_API_KEY " ) :
detected_providers . add ( " x-ai " )
if all_env . get ( " MISTRAL_API_KEY " ) :
detected_providers . add ( " mistralai " )
2026-05-29 16:24:20 +00:00
if all_env . get ( " OPENCODE_ZEN_API_KEY " ) or all_env . get ( " OPENCODE_API_KEY " ) :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
detected_providers . add ( " opencode-zen " )
2026-05-29 16:24:20 +00:00
if all_env . get ( " OPENCODE_GO_API_KEY " ) or all_env . get ( " OPENCODE_API_KEY " ) :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
detected_providers . add ( " opencode-go " )
2026-05-23 07:56:23 +00:00
# AWS Bedrock uses IAM credentials rather than a single API key.
# Detect when both access key and secret are available (#2720).
if all_env . get ( " AWS_ACCESS_KEY_ID " ) and all_env . get ( " AWS_SECRET_ACCESS_KEY " ) :
detected_providers . add ( " bedrock " )
2026-05-09 19:20:01 +00:00
# LM Studio: detect via LM_API_KEY + LM_BASE_URL in ~/.hermes/.env
if all_env . get ( " LM_API_KEY " ) and all_env . get ( " LM_BASE_URL " ) :
detected_providers . add ( " lmstudio " )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy (#1127)
* fix(#604): model picker shows all configured providers
Two fixes to ensure the model picker surface every provider a user has
configured:
1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
(→ mistralai). Previously these providers were only detectable via
hermes auth or credential pool, not via environment variables.
2. Added config.yaml providers section scanning. Users who configure
providers in config.yaml (e.g. providers.anthropic.api_key) without
setting the corresponding env var will now see those providers in the
model picker. Only providers with known model catalogs are added.
- Added 12 regression tests
* fix(#1112): allow Google Fonts in CSP style-src and font-src
Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.
- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass
* fix(#1118): retry api() calls on network errors after long idle
After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.
- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests
* feat(#1116): composer placeholder reflects active profile name
When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.
- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests
* feat(#1097): drag and drop workspace files into chat composer
Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.
- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
inserts @path with smart spacing and cursor positioning
- Added 9 regression tests
* fix(#1096): copy buttons work — add clipboard-write Permissions-Policy
Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.
- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
to execCommand('copy'). _fallbackCopy extracted as separate function
with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests
* chore: CHANGELOG for v0.50.223
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 22:29:02 +00:00
# Also detect providers explicitly listed in config.yaml providers section.
# A user may configure a provider key via config.yaml providers.<name>.api_key
# without setting the corresponding env var. (#604)
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
#
2026-05-17 00:21:50 +00:00
# Gating: only seed picker groups for keys whose canonical id is known
# to ``_PROVIDER_MODELS`` / ``_PROVIDER_DISPLAY``, or whose value is a
# dict-shaped provider config (custom/local). Scalar siblings under
# ``providers:`` (e.g. ``providers.only_configured: true``) are config
# flags, not providers, and must not render as phantom picker groups
# like ``Only-Configured`` (#2399).
#
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
# Canonicalise the id slug here so a user with ``providers.opencode_go``
# (underscore variant) doesn't see TWO provider groups in the picker —
# one for the canonical ``opencode-go`` from active_provider detection
# and a phantom ``Opencode_Go`` group for the config-key form (#1568).
# The same applies to mixed-case ids like ``OpenCode-Go`` and to
# legitimate aliases like ``z-ai`` → ``zai``.
2026-06-11 20:36:53 +00:00
_cfg_providers = _get_providers_cfg ( )
2026-05-14 16:02:56 +00:00
# Map canonical provider IDs back to raw config keys so the
# generic-provider branch can preserve mixed-case/underscore
# provider_cfg values (#2245).
_canonical_to_raw_provider_key : dict [ str , str ] = { }
v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy (#1127)
* fix(#604): model picker shows all configured providers
Two fixes to ensure the model picker surface every provider a user has
configured:
1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
(→ mistralai). Previously these providers were only detectable via
hermes auth or credential pool, not via environment variables.
2. Added config.yaml providers section scanning. Users who configure
providers in config.yaml (e.g. providers.anthropic.api_key) without
setting the corresponding env var will now see those providers in the
model picker. Only providers with known model catalogs are added.
- Added 12 regression tests
* fix(#1112): allow Google Fonts in CSP style-src and font-src
Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.
- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass
* fix(#1118): retry api() calls on network errors after long idle
After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.
- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests
* feat(#1116): composer placeholder reflects active profile name
When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.
- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests
* feat(#1097): drag and drop workspace files into chat composer
Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.
- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
inserts @path with smart spacing and cursor positioning
- Added 9 regression tests
* fix(#1096): copy buttons work — add clipboard-write Permissions-Policy
Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.
- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
to execCommand('copy'). _fallbackCopy extracted as separate function
with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests
* chore: CHANGELOG for v0.50.223
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 22:29:02 +00:00
if isinstance ( _cfg_providers , dict ) :
2026-05-17 00:21:50 +00:00
for _pid_key , _provider_cfg in _cfg_providers . items ( ) :
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
_canonical = _canonicalise_provider_id ( _pid_key )
if not _canonical :
continue
2026-05-17 00:21:50 +00:00
# See the gating comment on the block above. ``_PROVIDER_MODELS``
# / ``_PROVIDER_DISPLAY`` membership accepts known providers and
# aliases; ``isinstance(_provider_cfg, dict)`` accepts custom
# entries that supply their own models/api_key/base_url. (#2399)
_is_known_provider = (
2026-06-05 18:35:26 +00:00
_canonical in _PROVIDER_MODELS
or _canonical in _PROVIDER_DISPLAY
or _is_plugin_model_provider ( _canonical )
2026-05-17 00:21:50 +00:00
)
_is_provider_config = isinstance ( _provider_cfg , dict )
2026-06-30 23:51:10 +00:00
_has_provider_route = False
if _is_provider_config :
_has_provider_route = any (
str ( _provider_cfg . get ( _route_key ) or " " ) . strip ( )
for _route_key in ( " api " , " base_url " , " api_key " , " key_env " )
)
2026-07-01 03:32:42 +00:00
# A models-only provider config (no api/base_url/api_key/key_env)
# is only admitted as evidence when it's the active/configured
# provider (e.g. the lmstudio-style custom shape from #1970).
# This must NOT re-open the door for a spurious duplicate alias
# of a known provider (e.g. ``copilot-2: {name: "copilot",
# models: {...}}`` from #644/dedup regression) — that case is
# still rejected because it isn't the active provider and it
# isn't a route-bearing config in its own right.
_has_models_only_active_route = (
not _has_provider_route
and _is_provider_config
and isinstance ( _provider_cfg . get ( " models " ) , ( dict , list ) )
and _provider_cfg [ " models " ]
and _canonical == _canonicalise_provider_id ( active_provider )
)
if not ( _is_known_provider or _has_provider_route or _has_models_only_active_route ) :
2026-05-17 00:21:50 +00:00
continue
2026-05-14 16:02:56 +00:00
_canonical_to_raw_provider_key . setdefault ( _canonical , _pid_key )
2026-05-17 00:21:50 +00:00
detected_providers . add ( _canonical )
v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy (#1127)
* fix(#604): model picker shows all configured providers
Two fixes to ensure the model picker surface every provider a user has
configured:
1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
(→ mistralai). Previously these providers were only detectable via
hermes auth or credential pool, not via environment variables.
2. Added config.yaml providers section scanning. Users who configure
providers in config.yaml (e.g. providers.anthropic.api_key) without
setting the corresponding env var will now see those providers in the
model picker. Only providers with known model catalogs are added.
- Added 12 regression tests
* fix(#1112): allow Google Fonts in CSP style-src and font-src
Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.
- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass
* fix(#1118): retry api() calls on network errors after long idle
After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.
- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests
* feat(#1116): composer placeholder reflects active profile name
When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.
- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests
* feat(#1097): drag and drop workspace files into chat composer
Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.
- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
inserts @path with smart spacing and cursor positioning
- Added 9 regression tests
* fix(#1096): copy buttons work — add clipboard-write Permissions-Policy
Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.
- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
to execCommand('copy'). _fallbackCopy extracted as separate function
with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests
* chore: CHANGELOG for v0.50.223
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 22:29:02 +00:00
2026-05-03 17:35:15 +00:00
def _configured_provider_for_base_url ( base_url : object ) - > str :
target = _normalize_base_url_for_match ( base_url )
if not target :
return " "
if isinstance ( model_cfg , dict ) :
model_base_url = _normalize_base_url_for_match ( model_cfg . get ( " base_url " ) )
if model_base_url == target :
2026-05-07 13:40:23 +00:00
provider_hint = _resolve_configured_provider_id (
model_cfg . get ( " provider " ) ,
cfg ,
base_url = base_url ,
)
2026-05-03 17:35:15 +00:00
if provider_hint :
return str ( provider_hint ) . strip ( ) . lower ( )
providers_cfg = cfg . get ( " providers " , { } )
if isinstance ( providers_cfg , dict ) :
for provider_key , provider_cfg in providers_cfg . items ( ) :
if not isinstance ( provider_cfg , dict ) :
continue
provider_base_url = _normalize_base_url_for_match (
provider_cfg . get ( " base_url " )
)
if provider_base_url == target :
provider_hint = _resolve_provider_alias ( provider_key )
if provider_hint :
return str ( provider_hint ) . strip ( ) . lower ( )
custom_providers_cfg = cfg . get ( " custom_providers " , [ ] )
if isinstance ( custom_providers_cfg , list ) :
for entry in custom_providers_cfg :
if not isinstance ( entry , dict ) :
continue
entry_base_url = _normalize_base_url_for_match ( entry . get ( " base_url " ) )
if entry_base_url != target :
continue
entry_name = str ( entry . get ( " name " ) or " " ) . strip ( )
if entry_name :
2026-05-11 05:48:46 +00:00
return _custom_provider_slug_from_name ( entry_name )
2026-05-03 17:35:15 +00:00
return " custom "
return " "
2026-05-18 08:07:09 +00:00
def _models_endpoint_for_base_url ( base_url : str ) - > str :
base = str ( base_url or " " ) . strip ( ) . rstrip ( " / " )
if base . endswith ( " /v1 " ) :
return base + " /models "
return base + " /v1/models "
def _extract_model_entries_from_payload ( data : object , provider : str ) - > list [ dict ] :
models_list = [ ]
if isinstance ( data , dict ) :
if " data " in data and isinstance ( data [ " data " ] , list ) :
models_list = data [ " data " ]
elif " models " in data and isinstance ( data [ " models " ] , list ) :
models_list = data [ " models " ]
models = [ ]
seen = set ( )
for model in models_list :
if not isinstance ( model , dict ) :
continue
model_id = (
model . get ( " id " , " " )
or model . get ( " name " , " " )
or model . get ( " model " , " " )
)
model_name = model . get ( " name " , " " ) or model . get ( " model " , " " ) or model_id
model_id = str ( model_id or " " ) . strip ( )
model_name = str ( model_name or " " ) . strip ( )
if not model_id or not model_name or model_id in seen :
continue
seen . add ( model_id )
label = _format_ollama_label ( model_id ) if provider in ( " ollama " , " ollama-cloud " ) else model_name
models . append ( { " id " : model_id , " label " : label } )
return models
2026-05-20 10:09:01 +00:00
def _custom_endpoint_error (
provider : str ,
exc : Exception ,
* ,
code : int | None = None ,
) - > dict :
provider_label = str ( provider or " custom " ) . replace ( " custom: " , " " )
status_code = code if code is not None else getattr ( exc , " code " , None )
if status_code in ( 401 , 403 ) :
return {
" kind " : " auth " ,
" code " : int ( status_code ) ,
" message " : f " Models endpoint returned { status_code } — check the API key for { provider_label } . " ,
}
if isinstance ( status_code , int ) :
return {
" kind " : " http " ,
" code " : int ( status_code ) ,
" message " : f " Models endpoint returned { status_code } for { provider_label } ; see logs. " ,
}
return {
" kind " : " network " ,
" code " : None ,
" message " : f " Models endpoint unreachable for { provider_label } ; verify base_url. " ,
}
2026-05-18 08:07:09 +00:00
def _read_custom_endpoint_models (
base_url : object ,
provider : str ,
* ,
api_key : object = " " ,
trusted_base_urls : tuple [ object , . . . ] = ( ) ,
2026-05-20 10:09:01 +00:00
) - > tuple [ list [ dict ] , dict | None ] :
2026-05-18 08:07:09 +00:00
base = str ( base_url or " " ) . strip ( )
if not base :
2026-05-20 10:09:01 +00:00
return [ ] , None
2026-04-06 21:29:06 +00:00
try :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
import ipaddress
2026-05-20 10:09:01 +00:00
import urllib . error
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
import urllib . request
2026-05-18 08:07:09 +00:00
import socket
2026-04-02 16:15:41 +00:00
2026-05-18 08:07:09 +00:00
endpoint_url = _models_endpoint_for_base_url ( base )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
headers = { }
2026-05-18 08:07:09 +00:00
key = str ( api_key or " " ) . strip ( )
if key :
headers [ " Authorization " ] = f " Bearer { key } "
2026-04-02 16:15:41 +00:00
2026-05-18 08:07:09 +00:00
# User-configured custom provider endpoints are explicitly trusted,
# but keep the same private-IP guard for non-matching targets used by
# the legacy active model.base_url path.
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 17:36:59 +00:00
_ssrf_trusted_hosts : set [ str ] = set ( )
2026-05-18 08:07:09 +00:00
for trusted in ( base , * trusted_base_urls ) :
_cp_parsed = urlparse (
str ( trusted ) if " :// " in str ( trusted ) else f " http:// { trusted } "
)
if _cp_parsed . hostname :
_ssrf_trusted_hosts . add ( _cp_parsed . hostname . lower ( ) )
parsed_url = urlparse ( endpoint_url if " :// " in endpoint_url else f " http:// { endpoint_url } " )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if parsed_url . scheme not in ( " " , " http " , " https " ) :
raise ValueError ( f " Invalid URL scheme: { parsed_url . scheme } " )
if parsed_url . hostname :
try :
resolved_ips = socket . getaddrinfo ( parsed_url . hostname , None )
for _ , _ , _ , _ , addr in resolved_ips :
addr_obj = ipaddress . ip_address ( addr [ 0 ] )
if addr_obj . is_private or addr_obj . is_loopback or addr_obj . is_link_local :
2026-05-18 08:07:09 +00:00
host_l = ( parsed_url . hostname or " " ) . lower ( )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
is_known_local = any (
2026-05-18 08:07:09 +00:00
k in host_l
for k in ( " ollama " , " localhost " , " 127.0.0.1 " , " lmstudio " , " lm-studio " )
) or host_l in _ssrf_trusted_hosts
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if not is_known_local :
2026-05-18 08:07:09 +00:00
raise ValueError ( f " SSRF: resolved hostname to private IP { addr [ 0 ] } " )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
except socket . gaierror :
pass
2026-05-18 08:07:09 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
req = urllib . request . Request ( endpoint_url , method = " GET " )
req . add_header ( " User-Agent " , " OpenAI/Python 1.0 " )
for k , v in headers . items ( ) :
req . add_header ( k , v )
2026-05-27 16:19:42 +00:00
with urllib . request . urlopen ( req , timeout = CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS ) as response : # nosec B310
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
data = json . loads ( response . read ( ) . decode ( " utf-8 " ) )
2026-05-20 10:09:01 +00:00
return _extract_model_entries_from_payload ( data , provider ) , None
except urllib . error . HTTPError as exc :
error = _custom_endpoint_error ( provider , exc , code = getattr ( exc , " code " , None ) )
logger . debug ( " Custom endpoint models fetch failed for provider %s : %s " , provider , error )
return [ ] , error
except Exception as exc :
error = _custom_endpoint_error ( provider , exc )
logger . debug ( " Custom endpoint unreachable or misconfigured for provider %s : %s " , provider , error )
return [ ] , error
2026-05-18 08:07:09 +00:00
# 4. Fetch models from custom endpoint if base_url is configured
auto_detected_models = [ ]
auto_detected_models_by_provider : dict [ str , list [ dict ] ] = { }
if cfg_base_url :
base_url = cfg_base_url . strip ( )
configured_provider = _configured_provider_for_base_url ( base_url )
provider = configured_provider or " custom "
provider_from_config = bool ( configured_provider )
parsed = urlparse ( base_url if " :// " in base_url else f " http:// { base_url } " )
host = ( parsed . netloc or parsed . path ) . lower ( )
if parsed . hostname and not provider_from_config :
try :
import ipaddress
addr = ipaddress . ip_address ( parsed . hostname )
if addr . is_private or addr . is_loopback or addr . is_link_local :
if " ollama " in host or " 127.0.0.1 " in host or " localhost " in host :
provider = " ollama "
elif " lmstudio " in host or " lm-studio " in host :
provider = " lmstudio "
else :
# Unknown loopback/private endpoint: route through
# the generic ``custom`` provider so the agent's
# auxiliary client (compression, vision, web
# extraction) takes the OpenAI-compat custom path
# with ``no-key-required`` semantics. Writing
# ``provider: local`` here used to break
# compression mid-conversation because ``local``
# is not a registered provider in
# ``hermes_cli.auth.PROVIDER_REGISTRY`` — see #1384.
provider = " custom "
except ValueError :
pass
api_key = " "
if isinstance ( model_cfg , dict ) :
api_key = ( model_cfg . get ( " api_key " ) or " " ) . strip ( )
if not api_key :
providers_cfg = cfg . get ( " providers " , { } )
if isinstance ( providers_cfg , dict ) :
for provider_key in filter ( None , [ active_provider , " custom " ] ) :
provider_cfg = providers_cfg . get ( provider_key , { } )
if isinstance ( provider_cfg , dict ) :
api_key = ( provider_cfg . get ( " api_key " ) or " " ) . strip ( )
if api_key :
break
if not api_key :
api_key_vars = (
" HERMES_API_KEY " ,
" HERMES_OPENAI_API_KEY " ,
" OPENAI_API_KEY " ,
" LOCAL_API_KEY " ,
" OPENROUTER_API_KEY " ,
" API_KEY " ,
)
for key in api_key_vars :
2026-06-20 06:24:07 +00:00
api_key = ( all_env . get ( key ) or _thread_local_env_value ( key ) or " " ) . strip ( )
2026-05-18 08:07:09 +00:00
if api_key :
break
_trusted_custom_bases : list [ object ] = [ cfg_base_url ]
_custom_providers_for_trust = cfg . get ( " custom_providers " , [ ] )
if isinstance ( _custom_providers_for_trust , list ) :
_trusted_custom_bases . extend (
_cp . get ( " base_url " )
for _cp in _custom_providers_for_trust
if isinstance ( _cp , dict ) and _cp . get ( " base_url " )
)
2026-05-20 10:09:01 +00:00
_active_endpoint_models , _active_endpoint_error = _read_custom_endpoint_models (
2026-05-18 08:07:09 +00:00
base_url ,
provider ,
api_key = api_key ,
trusted_base_urls = tuple ( _trusted_custom_bases ) ,
2026-05-20 10:09:01 +00:00
)
for auto_model in _active_endpoint_models :
2026-05-18 08:07:09 +00:00
auto_detected_models . append ( auto_model )
provider_key = provider . lower ( )
auto_detected_models_by_provider . setdefault ( provider_key , [ ] ) . append ( auto_model )
detected_providers . add ( provider_key )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_custom_providers_cfg = cfg . get ( " custom_providers " , [ ] )
_named_custom_groups : dict = { }
2026-05-20 10:09:01 +00:00
_named_custom_errors : dict [ str , dict ] = { }
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if isinstance ( _custom_providers_cfg , list ) :
2026-05-09 02:31:06 +00:00
_seen_custom_ids = set ( )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
for _cp in _custom_providers_cfg :
if not isinstance ( _cp , dict ) :
continue
_cp_name = ( _cp . get ( " name " ) or " " ) . strip ( )
2026-05-07 13:40:23 +00:00
_slug = _custom_provider_slug_from_name ( _cp_name ) if _cp_name else None
if _slug and _slug not in _named_custom_groups :
_named_custom_groups [ _slug ] = ( _cp_name , [ ] )
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 17:36:59 +00:00
2026-05-18 08:07:09 +00:00
_cp_base_url = str ( _cp . get ( " base_url " ) or " " ) . strip ( )
2026-06-16 17:44:10 +00:00
_cp_api_key = str ( _cp . get ( " api_key " ) or " " ) . strip ( )
if not _cp_api_key :
_cp_key_env = str ( _cp . get ( " key_env " ) or " " ) . strip ( )
if _cp_key_env :
2026-06-20 06:24:07 +00:00
_cp_api_key = _thread_local_env_value ( _cp_key_env ) . strip ( )
2026-06-16 17:44:10 +00:00
# Fallback: check credential pool for both api_key and base_url
if ( not _cp_api_key or not _cp_base_url ) and _slug :
try :
from api . config import _has_explicit_pool_credentials
if _has_explicit_pool_credentials ( _slug ) :
from agent . credential_pool import load_pool
_resolved = _resolve_provider_alias ( _slug )
_pool = load_pool ( _resolved )
if _pool :
_entry = _pool . select ( )
if _entry :
if not _cp_api_key :
2026-06-16 18:09:01 +00:00
_cp_api_key = getattr ( _entry , " runtime_api_key " , " " ) or " "
2026-06-16 17:44:10 +00:00
if not _cp_base_url :
_cp_base_url = str ( getattr ( _entry , " base_url " , " " ) or " " ) . strip ( )
except ImportError :
pass
2026-05-20 15:24:10 +00:00
2026-06-16 17:44:10 +00:00
if _slug and _cp_base_url :
2026-05-20 15:24:10 +00:00
# Check if user has configured models in config.yaml —
# configured models take priority over live /v1/models
# discovery (same as hermes-agent model_switch.py Section 4
# patch). Without this check, ZenMux and similar aggregator
# gateways would show hundreds of online models instead of
# the user's curated list.
_cp_configured_models = _cp . get ( " models " )
_cp_has_configured_models = (
isinstance ( _cp_configured_models , ( dict , list ) )
and len ( _cp_configured_models ) > 0
)
2026-05-20 10:09:01 +00:00
_live_models = auto_detected_models_by_provider . get ( _slug )
_live_error = None
2026-05-20 15:24:10 +00:00
if _cp_has_configured_models :
# Skip the live /v1/models probe when an allowlist
# exists — the curated list wins and probe failures
# should not surface as a user-facing diagnostic in
# that case. Still respect any pre-warm result that
# ``auto_detected_models_by_provider`` already
# populated (cheap to keep).
if _live_models is None :
_live_models = [ ]
elif _live_models is None :
2026-05-20 10:09:01 +00:00
_live_models , _live_error = _read_custom_endpoint_models (
_cp_base_url ,
_slug ,
api_key = _cp_api_key ,
trusted_base_urls = ( _cp_base_url , ) ,
)
if _live_error :
_named_custom_errors [ _slug ] = _live_error
detected_providers . add ( _slug )
2026-05-18 08:07:09 +00:00
for _live_model in _live_models :
_live_id = str ( _live_model . get ( " id " ) or " " ) . strip ( )
if not _live_id :
continue
_dedup_key = f " { _slug } : { _live_id } "
if _dedup_key in _seen_custom_ids :
continue
_seen_custom_ids . add ( _dedup_key )
detected_providers . add ( _slug )
_cp_option_id = _live_id
if active_provider != _slug and not _cp_option_id . startswith ( " @ " ) :
_cp_option_id = f " @ { _slug } : { _cp_option_id } "
_named_custom_groups [ _slug ] [ 1 ] . append (
{ " id " : _cp_option_id , " label " : _live_model . get ( " label " ) or _get_label_for_model ( _live_id , [ ] ) }
)
# Collect configured model IDs as a fallback/sticky entry after live discovery.
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 17:36:59 +00:00
_cp_model_ids : list [ str ] = [ ]
_cp_model = _cp . get ( " model " , " " )
if _cp_model :
_cp_model_ids . append ( _cp_model )
_cp_models_dict = _cp . get ( " models " )
if isinstance ( _cp_models_dict , dict ) :
for _m_id in _cp_models_dict :
if isinstance ( _m_id , str ) and _m_id . strip ( ) and _m_id not in _cp_model_ids :
_cp_model_ids . append ( _m_id . strip ( ) )
2026-05-16 04:38:39 +00:00
elif isinstance ( _cp_models_dict , list ) :
for _item in _cp_models_dict :
if isinstance ( _item , str ) :
_mid = _item . strip ( )
if _mid and _mid not in _cp_model_ids :
_cp_model_ids . append ( _mid )
elif isinstance ( _item , dict ) :
_mid = str ( _item . get ( " id " ) or _item . get ( " model " ) or _item . get ( " name " ) or " " ) . strip ( )
if _mid and _mid not in _cp_model_ids :
_cp_model_ids . append ( _mid )
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 17:36:59 +00:00
for _cp_model in _cp_model_ids :
2026-05-09 02:31:06 +00:00
_dedup_key = f " { _slug } : { _cp_model } " if _slug else _cp_model
if _cp_model and _dedup_key not in _seen_custom_ids :
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 17:36:59 +00:00
_cp_label = _get_label_for_model ( _cp_model , [ ] )
2026-05-09 02:31:06 +00:00
_seen_custom_ids . add ( _dedup_key )
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 17:36:59 +00:00
if _slug :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
detected_providers . add ( _slug )
2026-05-01 15:14:12 +00:00
_cp_option_id = _cp_model
if active_provider != _slug and not _cp_option_id . startswith ( " @ " ) :
_cp_option_id = f " @ { _slug } : { _cp_option_id } "
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 17:36:59 +00:00
_named_custom_groups [ _slug ] [ 1 ] . append (
2026-05-01 15:14:12 +00:00
{ " id " : _cp_option_id , " label " : _cp_label }
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 17:36:59 +00:00
)
else :
auto_detected_models . append ( { " id " : _cp_model , " label " : _cp_label } )
detected_providers . add ( " custom " )
2026-04-22 20:18:02 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_has_custom_providers = isinstance ( _custom_providers_cfg , list ) and len ( _custom_providers_cfg ) > 0
if active_provider and active_provider != " custom " and not _has_custom_providers :
detected_providers . discard ( " custom " )
for _slug in list ( detected_providers ) :
if _slug . startswith ( " custom: " ) and not _has_custom_providers :
detected_providers . discard ( _slug )
elif active_provider == " custom " and _has_custom_providers :
_has_unnamed = any (
isinstance ( _cp , dict ) and not ( _cp . get ( " name " ) or " " ) . strip ( )
for _cp in _custom_providers_cfg
)
if not _has_unnamed :
detected_providers . discard ( " custom " )
2026-05-07 13:40:23 +00:00
_named_custom_slugs = _named_custom_provider_slugs ( cfg )
_base_matched_named_slug = _named_custom_provider_slug_for_base_url ( cfg_base_url , cfg )
if _base_matched_named_slug and _named_custom_slugs :
for _pid in list ( detected_providers ) :
_pid_norm = str ( _pid or " " ) . strip ( ) . lower ( )
if _pid_norm . startswith ( " custom: " ) and _pid_norm not in _named_custom_slugs :
detected_providers . discard ( _pid )
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
# Filter providers if providers.only_configured is set
providers_cfg = cfg . get ( " providers " , { } )
only_show_configured = providers_cfg . get ( " only_configured " , False ) if isinstance ( providers_cfg , dict ) else False
if only_show_configured :
configured_providers = set ( )
if active_provider :
configured_providers . add ( active_provider )
cfg_providers = cfg . get ( " providers " , { } )
if isinstance ( cfg_providers , dict ) :
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
# Canonicalise here too — same rationale as #1568 detection
# path. Without this, only_show_configured mode could
# exclude detected ``opencode-go`` because configured_providers
# only has the underscore-variant key from config.yaml.
configured_providers . update (
_canonicalise_provider_id ( k ) or k for k in cfg_providers . keys ( )
)
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
# Only show providers that are both detected and configured
detected_providers = detected_providers . intersection ( configured_providers )
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
# Post-collection dedup: re-canonicalise every entry so any path that
# added a non-canonical id (mixed-case from auth-store, raw config-key,
# legacy alias) gets folded onto the canonical key. Belt-and-braces for
# #1568 — protects against future regressions in any of the ~25
# `detected_providers.add(...)` callsites without auditing each one.
# The fold is idempotent for already-canonical ids, so safe to run
# unconditionally.
if detected_providers :
_canonicalised_detected = set ( )
for _pid in detected_providers :
_c = _canonicalise_provider_id ( _pid ) or _pid
_canonicalised_detected . add ( _c )
detected_providers = _canonicalised_detected
2026-06-30 23:51:10 +00:00
try :
_moa_cfg = cfg . get ( " moa " ) if isinstance ( cfg , dict ) else None
if isinstance ( _moa_cfg , dict ) :
_moa_enabled = bool ( _moa_cfg . get ( " enabled " , True ) )
_moa_presets = _moa_cfg . get ( " presets " )
if _moa_enabled and isinstance ( _moa_presets , dict ) and _moa_presets :
detected_providers . add ( " moa " )
except Exception :
logger . debug ( " Failed to inspect MoA presets for model picker " , exc_info = True )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# 5. Build model groups
if detected_providers :
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_picker_selected_model_id = (
( model_cfg . get ( " model " ) if isinstance ( model_cfg , dict ) else None )
or default_model
or None
)
def _append_picker_group (
provider_label : str ,
provider_id : str ,
raw_models : list [ dict ] | None ,
* ,
models_endpoint_error : dict | None = None ,
apply_prefix : bool = True ,
decorate_overflow_label : bool = False ,
allow_empty : bool = False ,
) - > None :
picker_models = copy . deepcopy ( raw_models or [ ] )
if apply_prefix :
picker_models = _apply_provider_prefix ( picker_models , provider_id , active_provider )
visible_models , extra_models = _split_picker_overflow_models (
picker_models ,
selected_model_id = _picker_selected_model_id ,
provider_id = provider_id ,
)
if not ( visible_models or extra_models or models_endpoint_error or allow_empty ) :
return
group_entry = {
" provider " : provider_label ,
" provider_id " : provider_id ,
" models " : visible_models ,
}
if decorate_overflow_label and extra_models :
group_entry [ " provider " ] = (
f " { provider_label } ( { len ( visible_models ) } of { len ( visible_models ) + len ( extra_models ) } ) "
)
if extra_models :
group_entry [ " extra_models " ] = extra_models
if models_endpoint_error :
group_entry [ " models_endpoint_error " ] = models_endpoint_error
groups . append ( group_entry )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
for pid in sorted ( detected_providers ) :
2026-05-08 08:58:21 +00:00
# Custom-provider PIDs are populated above via the
# _named_custom_groups branch (or skipped intentionally).
# They MUST NOT fall through to the auto_detected_models
# fallback below, otherwise the active provider's models
# get copied into a phantom Custom group with mismatched
# provider prefixes (#1881).
if pid . startswith ( " custom: " ) :
if pid in _named_custom_groups :
_nc_display , _nc_models = _named_custom_groups [ pid ]
# If all named-group models were deduped (already auto-detected
# from base_url /v1/models), fall back to auto-detected models
# instead of silently dropping the group (issue #1619).
#
# Per Opus advisor on stage-295: the load-bearing fix for the
# reporter's symptom is the api/routes.py:/api/models/live
# broadening to handle custom:* slugs. This block is defensive
# belt-and-braces — under current _named_custom_groups
# population logic (atomic add+append inside the same dedup
# guard at line ~2640), an empty list shouldn't reach here.
# Kept for future-proofing in case the population logic
# changes (e.g. supporting model-less custom_providers entries).
if not _nc_models :
_nc_models = auto_detected_models_by_provider . get ( pid , [ ] )
2026-05-20 10:09:01 +00:00
if _nc_models or pid in _named_custom_errors :
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_append_picker_group (
_nc_display ,
pid ,
_nc_models ,
models_endpoint_error = _named_custom_errors . get ( pid ) ,
apply_prefix = False ,
)
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
continue
2026-06-05 18:35:26 +00:00
provider_name = _effective_provider_display_name ( pid , _PROVIDER_DISPLAY )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if pid == " openrouter " :
2026-05-03 19:18:44 +00:00
# OpenRouter has two model surfaces:
# (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models()
# — the canonical agent-ready list, applies a tool-support filter
# (Kilo-Org/kilocode#9068) that hides image/completion-only models
# (2) free-tier `:free` variants — newly-added models OpenRouter ships
# experimentally that may not yet advertise `tools` in supported_parameters
# (see #1426). These get filtered out of (1) but users want them visible.
#
# Strategy: take the live curated list as the base, then augment with a
# separate live-fetch of OpenRouter's /v1/models filtered to free-tier-only.
# Free-tier entries get a "(free)" label suffix so the picker is honest about
# what the user is selecting. Falls back to the static _FALLBACK_MODELS list
# when both live fetches fail (offline, transient API error, test env).
raw_models = [ ]
seen_ids = set ( )
try :
from hermes_cli . models import (
fetch_openrouter_models as _fetch_or_models ,
)
live_curated = _fetch_or_models ( ) or [ ]
for mid , _desc in live_curated :
if mid and mid not in seen_ids :
seen_ids . add ( mid )
raw_models . append ( { " id " : mid , " label " : mid } )
except Exception :
logger . warning ( " Failed to load OpenRouter curated catalog from hermes_cli " )
# Free-tier live fetch — bypasses the tool-support filter so models
# OpenRouter has flagged free but hasn't yet annotated with tools=[]
# (or that have tools=[] but the user explicitly wants to try) appear.
try :
import urllib . request as _urlreq
_req = _urlreq . Request (
" https://openrouter.ai/api/v1/models " ,
headers = { " Accept " : " application/json " } ,
)
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
free_tier_models = [ ]
selected_free_tier_model = None
2026-05-03 19:18:44 +00:00
with _urlreq . urlopen ( _req , timeout = 8.0 ) as _resp :
_payload = json . loads ( _resp . read ( ) . decode ( ) )
for _item in _payload . get ( " data " , [ ] ) or [ ] :
if not isinstance ( _item , dict ) :
continue
_mid = str ( _item . get ( " id " ) or " " ) . strip ( )
if not _mid or _mid in seen_ids :
continue
_pricing = _item . get ( " pricing " ) or { }
try :
_is_free = (
float ( _pricing . get ( " prompt " , " 0 " ) or " 0 " ) == 0
and float ( _pricing . get ( " completion " , " 0 " ) or " 0 " ) == 0
)
except ( TypeError , ValueError ) :
_is_free = False
# Also include explicit `:free` suffix variants
_is_free = _is_free or _mid . endswith ( " :free " )
if not _is_free :
continue
_name = (
str ( _item . get ( " name " ) or " " ) . strip ( ) or _mid
)
# Strip provider prefix from name for display, append (free)
_label = _name . split ( " / " ) [ - 1 ] if " / " in _name else _name
if " (free) " not in _label . lower ( ) :
_label = f " { _label } (free) "
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_entry = { " id " : _mid , " label " : _label }
free_tier_models . append ( _entry )
if _model_matches_picker_selection (
_mid ,
_picker_selected_model_id ,
" openrouter " ,
) :
selected_free_tier_model = _entry
if len ( free_tier_models ) > _OPENROUTER_FREE_TIER_AUGMENT_CAP :
free_tier_models = free_tier_models [ : _OPENROUTER_FREE_TIER_AUGMENT_CAP ]
if (
selected_free_tier_model
and not any (
m . get ( " id " ) == selected_free_tier_model . get ( " id " )
for m in free_tier_models
)
) :
free_tier_models [ - 1 ] = selected_free_tier_model
for _entry in free_tier_models :
seen_ids . add ( _entry [ " id " ] )
raw_models . append ( _entry )
2026-05-03 19:18:44 +00:00
except Exception :
logger . debug ( " OpenRouter free-tier live fetch unavailable; using fallback " )
if not raw_models :
# Both live fetches failed — fall back to the curated static list.
# Deepcopy so dedup/prefix mutation downstream does not bleed
# into the module-level catalog.
raw_models = [
{ " id " : m [ " id " ] , " label " : m [ " label " ] }
for m in _FALLBACK_MODELS
if m . get ( " provider " ) == " OpenRouter "
]
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_append_picker_group ( " OpenRouter " , " openrouter " , raw_models )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
elif pid == " ollama-cloud " :
raw_models = [ ]
try :
from hermes_cli . models import provider_model_ids as _provider_model_ids
2026-04-22 20:18:02 +00:00
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
raw_models = [
{ " id " : mid , " label " : _format_ollama_label ( mid ) }
for mid in ( _provider_model_ids ( " ollama-cloud " ) or [ ] )
]
except Exception :
logger . warning ( " Failed to load Ollama Cloud models from hermes_cli " )
2026-05-05 06:10:36 +00:00
if raw_models :
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_append_picker_group ( provider_name , pid , raw_models )
2026-05-05 06:10:36 +00:00
elif pid == " openai-codex " :
# Codex account catalogs drift faster than WebUI releases
# (for example gpt-5.3-codex-spark in #1680). Ask the
# agent's Codex resolver first so /api/models inherits the
# live Codex API / local ~/.codex cache / static fallback
# chain instead of freezing the picker to WebUI's curated
# _PROVIDER_MODELS snapshot.
raw_models = [ ]
codex_ids = [ ]
try :
from hermes_cli . models import provider_model_ids as _provider_model_ids
codex_ids = [ mid for mid in ( _provider_model_ids ( " openai-codex " ) or [ ] ) if mid ]
except Exception :
logger . warning ( " Failed to load OpenAI Codex models from hermes_cli " )
for mid in _read_visible_codex_cache_model_ids ( ) :
if mid not in codex_ids :
codex_ids . append ( mid )
raw_models = [
{ " id " : mid , " label " : _get_label_for_model ( mid , [ ] ) }
for mid in codex_ids
]
if not raw_models :
raw_models = copy . deepcopy ( _PROVIDER_MODELS . get ( " openai-codex " , [ ] ) )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if raw_models :
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_append_picker_group ( provider_name , pid , raw_models )
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
elif pid == " nous " :
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
# Nous Portal exposes a curated catalog (~30 models on most
# accounts, up to several hundred for enterprise tiers) via
# inference-api.nousresearch.com. Like ollama-cloud, we
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
# live-fetch through hermes_cli.models.provider_model_ids()
# rather than relying on the static four-entry list, which
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
# chronically drifts out of date (#1538).
#
# When the catalog exceeds _NOUS_FEATURED_THRESHOLD (~25)
# the picker dropdown gets a curated subset to stay
# scannable — the full list is still returned under
# "extra_models" for the slash-command autocomplete and
# the dynamic-label map (#1567). The optgroup label is
# decorated with the truncation count so users know more
# exists.
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
raw_models = [ ]
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
live_fetch_failed = False
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
try :
from hermes_cli . models import provider_model_ids as _provider_model_ids
live_ids = _provider_model_ids ( " nous " ) or [ ]
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
except Exception :
logger . warning ( " Failed to load Nous Portal models from hermes_cli " )
live_ids = [ ]
live_fetch_failed = True
if live_ids :
featured_ids , extras_ids = _build_nous_featured_set (
live_ids ,
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
selected_model_id = _picker_selected_model_id ,
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
)
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
ordered_ids = featured_ids + extras_ids
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
raw_models = [
{ " id " : f " @nous: { mid } " , " label " : _format_nous_label ( mid ) }
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
for mid in ordered_ids
fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00
]
elif not live_fetch_failed :
# Live-fetch returned an empty list AND did not raise —
# the user is gated as authenticated by detection above
# but the catalog endpoint replied with no models.
# Showing the static 4-entry curated list here would
# contradict the providers card (which always shows
# the live catalog) — exactly the asymmetry #1567
# reports. Omit the Nous group entirely; the providers
# card already tells the truth, and a transient empty
# response will self-heal on the next cache rebuild.
logger . warning (
" Nous Portal authenticated but live-fetch returned empty — "
" omitting from picker (will retry on next cache rebuild) "
)
else :
# hermes_cli unavailable / raised — fall back to the
# curated 4-entry static list so the picker is never
# empty in this degraded state. This matches pre-#1538
# behaviour for environments without hermes_cli (test
# envs, package mismatches, isolated WebUI builds).
fix: Nous Portal full live catalog + dropdown cache invalidation on provider remove
Closes #1538, #1539. Two related dropdown-staleness bugs reported by Deor
(Discord, May 03 2026).
#1538 — Nous Portal picker showed only 4 hardcoded models
=========================================================
The Settings → Default Model picker, the composer model dropdown, the
/model slash command, and the Settings → Providers card all showed only
four Nous models (Claude Opus 4.6, Claude Sonnet 4.6, GPT-5.4 Mini, Gemini
3.1 Pro Preview) because `_PROVIDER_MODELS["nous"]` had four hardcoded
entries and `_build_available_models_uncached()` fell through to the
generic `pid in _PROVIDER_MODELS` branch.
The actual Nous Portal catalog has 30 models live — Claude Opus 4.7, GPT-5.5,
Kimi K2.6, MiniMax M2.7, Gemini 3.1 Pro/Flash, several Xiaomi/Tencent/StepFun
entries, and more.
Fix:
- New `_format_nous_label()` helper in `api/config.py` — reuses the
`_format_ollama_label()` token rules, drops the vendor namespace, and
appends ` (via Nous)` so labels disambiguate from same-named direct-
provider entries (e.g. "Claude Opus 4.7" via direct Anthropic).
- New `elif pid == "nous":` branch in `_build_available_models_uncached()`
mirroring the Ollama Cloud pattern: live-fetch through
`hermes_cli.models.provider_model_ids("nous")`, prefix every id with
`@nous:` (matches the existing routing convention from PR-era #854 and
pinned in tests/test_nous_portal_routing.py), fall back to the curated
4-entry static list when hermes_cli is unavailable.
- Same fix applied to `api/providers.py:get_providers()` — that's the
separate code path that builds Settings → Providers card models, and
it had the identical bug shape.
#1539 — Removed provider lingered in dropdowns until restart
============================================================
After Settings → Providers → Remove, the provider's models still appeared
in every model dropdown until the page was reloaded. The server-side
TTL cache was correctly flushed (`set_provider_key()` calls
`invalidate_models_cache()` on both add and remove) but JS-side caches
were never dropped:
- `_slashModelCache` / `_slashModelCachePromise` (commands.js) — feeds
the `/model` slash-command suggestions.
- `_dynamicModelLabels` / `window._configuredModelBadges` (ui.js) —
populated by `populateModelDropdown()` on app boot and profile switch.
Pre-fix, `_removeProviderKey()` only called `loadProvidersPanel()`
which refreshed the providers card list but never asked any consumer
to re-fetch /api/models.
Fix:
- `static/commands.js`: new `_invalidateSlashModelCache()` helper that
nulls both cache slots, exposed on `window` (typeof-guarded so the
module remains importable in headless vm contexts — needed by the
existing tests/test_cli_only_slash_commands.py harness).
- `static/panels.js`: new `_refreshModelDropdownsAfterProviderChange()`
helper that calls the invalidator + `populateModelDropdown()`, wrapped
in try/catch so the providers panel update never breaks if a
downstream module hasn't loaded yet. Both `_saveProviderKey` and
`_removeProviderKey` invoke it (defense-in-depth: same staleness shape
applies to the add path too).
Tests
-----
- `tests/test_issue1538_nous_live_catalog.py` (12 tests): live-fetch
surfaces ≥20 entries, every id starts with `@nous:`, every label ends
with ` (via Nous)`, recent flagships (Opus 4.7, GPT-5.5, Kimi K2.6,
Gemini 3.1 Pro, MiniMax M2.7) reach the dropdown, static fallback
works when hermes_cli raises, label formatter unit tests (vendor
namespace stripping, variant rendering, MiniMax mixed-case), the
curated static list and its routing invariants are preserved.
- `tests/test_issue1539_provider_removal_dropdown_invalidation.py`
(11 tests): invalidator helper exists and clears both cache slots,
exposed on window with typeof guard, both save and remove paths
invoke the dropdown flush, helper calls both invalidator and
populateModelDropdown, helper is resilient to missing modules,
helper does not block panel refresh, server-side
`set_provider_key → invalidate_models_cache` invariant pinned.
Verified live on port 8789: `/api/models` Nous group returns 30
models (was 4); browser `document.getElementById('modelSelect')`
exposes 30 options under the "Nous Portal" group; the dropdown-flush
helper is callable from the browser and round-trip rebuild keeps the
dropdown at 30 options.
Test counts:
- Full pytest: 4013 passed, 2 skipped, 3 xpassed, 0 failures
(was 3990 → 4013, +23 from this PR).
- QA harness pytest: 20 passed.
- Browser API sanity: 11/11 passed.
- Agent Browser CDP: 21/23 passed (the 2 SSE liveness failures
reproduce on master and are unrelated to this PR).
2026-05-03 18:01:51 +00:00
raw_models = copy . deepcopy ( _PROVIDER_MODELS . get ( " nous " , [ ] ) )
if raw_models :
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_append_picker_group (
provider_name ,
pid ,
raw_models ,
apply_prefix = False ,
decorate_overflow_label = True ,
)
2026-05-09 19:20:01 +00:00
elif pid == " lmstudio " :
# LM Studio is a local server — fetch live loaded models via
# the OpenAI-compatible /v1/models endpoint (#WebUI).
2026-05-11 06:06:58 +00:00
#
# Two-tier lookup, each in its own try so a failure in one
# does not abort the other (the bug pattern that broke
# tests/test_issue1527_lmstudio_base_url_classification on
# CI environments where hermes_cli isn't importable —
# ImportError in the cli tier was hijacking the whole
# branch and silently skipping the urlopen fallback).
2026-05-09 19:20:01 +00:00
raw_models = [ ]
2026-05-11 06:06:58 +00:00
lm_ids : list [ str ] = [ ]
2026-05-09 19:20:01 +00:00
try :
from hermes_cli . models import provider_model_ids as _provider_model_ids
lm_ids = _provider_model_ids ( " lmstudio " ) or [ ]
except Exception :
2026-05-11 06:06:58 +00:00
logger . debug ( " hermes_cli LM Studio lookup unavailable; using urlopen fallback " )
if lm_ids :
raw_models = [ { " id " : mid , " label " : mid } for mid in lm_ids ]
else :
# Fallback: fetch /models directly from the configured
# base URL. Looks for the URL in either
# `cfg["providers"]["lmstudio"]["base_url"]` or
# `cfg["model"]["base_url"]` (via _get_provider_base_url),
# so the historical model-block config shape still works.
2026-06-11 20:36:53 +00:00
lm_cfg = _get_provider_cfg ( " lmstudio " )
2026-05-11 06:06:58 +00:00
lm_base_url = _get_provider_base_url ( " lmstudio " ) or " "
2026-06-11 20:36:53 +00:00
lm_api_key = str ( lm_cfg . get ( " api_key " ) or " " ) . strip ( )
2026-05-11 06:06:58 +00:00
if lm_base_url :
headers = { " User-Agent " : " OpenAI/Python 1.0 " }
if lm_api_key :
headers [ " Authorization " ] = f " Bearer { lm_api_key } "
endpoint = ( lm_base_url + " /models " ) . rstrip ( " / " )
try :
import urllib . request as _urlreq
req = _urlreq . Request ( endpoint , method = " GET " , headers = headers )
with _urlreq . urlopen ( req , timeout = 5 ) as resp :
lm_data = json . loads ( resp . read ( ) . decode ( ) )
for m in ( lm_data . get ( " data " ) or [ ] ) :
if isinstance ( m , dict ) :
mid = str ( m . get ( " id " ) or " " ) . strip ( )
if mid and { " id " : mid , " label " : mid } not in raw_models :
raw_models . append ( { " id " : mid , " label " : mid } )
except Exception :
logger . debug ( " LM Studio /models fetch failed at %s " , endpoint )
2026-05-09 19:20:01 +00:00
if raw_models :
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_append_picker_group ( provider_name , pid , raw_models )
2026-06-05 18:35:26 +00:00
elif (
pid in _PROVIDER_MODELS
2026-06-30 23:51:10 +00:00
or pid in _PROVIDER_DISPLAY
2026-06-05 18:35:26 +00:00
or pid in _canonical_to_raw_provider_key
or _is_plugin_model_provider ( pid )
) :
2026-05-14 16:02:56 +00:00
# Look up provider_cfg using the original raw key from
# config.yaml so that mixed-case / underscore keys like
# ``CLIPpoxy`` or ``snake_case_provider`` still resolve
# (#2245). Fall back to the canonical pid for providers
# that appear in _PROVIDER_MODELS but not in cfg.
_raw_key = _canonical_to_raw_provider_key . get ( pid , pid )
2026-06-11 20:36:53 +00:00
provider_cfg = _get_provider_cfg ( _raw_key )
2026-05-05 23:48:56 +00:00
raw_models = [ ]
# User-configured model allowlists are explicit local
2026-07-01 01:08:51 +00:00
# source-of-truth for custom/plugin providers, AND for most
# built-in Hermes providers (e.g. providers.anthropic.models
# is a real picker allowlist — see #644). Copilot is the
# exception: it uses providers.copilot.models as a per-model
# settings map (reasoning_effort, limits, etc.), so treating
# that as an allowlist collapsed the Copilot picker to
# whichever model had local settings. Only Copilot skips the
# config-models allowlist branch and asks Hermes CLI for the
# live catalog first (static _PROVIDER_MODELS is fallback only).
_uses_models_as_settings_map = pid == " copilot "
2026-06-30 23:51:10 +00:00
if (
2026-07-01 01:08:51 +00:00
not _uses_models_as_settings_map
2026-06-30 23:51:10 +00:00
and isinstance ( provider_cfg , dict )
and " models " in provider_cfg
) :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
cfg_models = provider_cfg [ " models " ]
if isinstance ( cfg_models , dict ) :
raw_models = [ { " id " : k , " label " : k } for k in cfg_models . keys ( ) ]
elif isinstance ( cfg_models , list ) :
2026-05-13 05:49:40 +00:00
raw_models = [ { " id " : k [ " id " ] if isinstance ( k , dict ) else k ,
" label " : k . get ( " label " , k [ " id " ] ) if isinstance ( k , dict ) else k }
for k in cfg_models ]
2026-05-05 23:48:56 +00:00
if not raw_models :
2026-07-01 00:16:26 +00:00
if pid == " moa " :
raw_models = _moa_preset_models_from_config ( cfg )
else :
raw_models = _models_from_live_provider_ids (
pid ,
_read_live_provider_model_ids ( pid ) ,
)
2026-05-05 23:48:56 +00:00
if not raw_models :
raw_models = copy . deepcopy ( _PROVIDER_MODELS . get ( pid , [ ] ) )
detected_models = auto_detected_models_by_provider . get ( pid , [ ] )
if detected_models and not raw_models :
raw_models = copy . deepcopy ( detected_models )
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_append_picker_group ( provider_name , pid , raw_models )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
else :
2026-05-03 17:35:15 +00:00
detected_models = auto_detected_models_by_provider . get ( pid )
if detected_models :
models_for_group = copy . deepcopy ( detected_models )
2026-06-16 21:45:43 +00:00
elif auto_detected_models and ( pid == " custom " or _is_known_model_provider ( pid ) ) :
2026-05-08 08:58:21 +00:00
# Don't fall back to the global auto_detected_models
# list for the bare "custom" PID when the active
# provider is something concrete (e.g. ai-gateway,
# openrouter). Those auto-detected entries already
# belong to the active provider's group — copying
# them into a Custom group too produces phantom
# duplicates with mismatched prefixes (#1881).
if pid == " custom " and active_provider and active_provider != " custom " :
models_for_group = [ ]
else :
models_for_group = copy . deepcopy ( auto_detected_models )
2026-05-03 17:35:15 +00:00
else :
2026-06-16 21:45:43 +00:00
# An unrecognized provider id with no catalog of its
# own must NOT be painted with the global
# auto_detected_models list. Otherwise a non-model
# credential_pool key (the Photon plugin's
# photon/photon_project/photon_user entries, #4324) or
# any future unknown id renders as a phantom provider
# carrying the active endpoint's entire model catalog.
# Such ids are dropped upstream by
# _is_known_model_provider() in the pool-detection
# loop; this omission is belt-and-braces matching the
# #1572/#7372 "omit rather than misattribute" posture.
2026-05-03 17:35:15 +00:00
models_for_group = [ ]
if models_for_group :
2026-05-03 06:41:11 +00:00
# Per-group deep copy so subsequent mutation by
# _deduplicate_model_ids() (which prefixes ids with
# @provider_id:) does not bleed into other groups
# that also fall through to this branch (#1511 root
# cause: multiple unconfigured providers all sharing
# the same auto_detected_models list reference would
# see every group's id rewritten to the FIRST
# provider's prefix, and labels accumulated every
# provider's name).
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
_append_picker_group (
provider_name ,
pid ,
models_for_group ,
apply_prefix = False ,
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
)
2026-05-18 19:28:46 +00:00
elif pid == " custom " and cfg_base_url :
# Anonymous custom endpoint: /v1/models probe may have
# failed (e.g. llama-server, lightweight relay), but the
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
# chat endpoint itself may still work. Add the group
2026-05-18 19:28:46 +00:00
# with an empty model list so the user can type a model
# ID manually rather than being blocked by a silent
# probe failure (#2542).
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
groups . append (
{
" provider " : provider_name ,
" provider_id " : pid ,
" models " : [ ] ,
}
)
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
else :
if default_model :
label = _get_label_for_model ( default_model , groups )
2026-04-12 07:11:41 +00:00
groups . append (
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
{ " provider " : " Default " , " provider_id " : " default " , " models " : [ { " id " : default_model , " label " : label } ] }
2026-04-12 07:11:41 +00:00
)
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
if default_model :
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
# Guard against provider-id values mistakenly stored in
# ``model.default``. The injection logic below puts ANY string
# into the picker as a fake option, so a stray provider id
# surfaces as a self-referential phantom model labelled e.g.
# ``Opencode GO`` — a 15th entry under the OpenCode Go group
# (#1568). The user's misconfig is real, but the picker is
# the wrong surface to surface it; we'd rather skip injection
# and emit a warning so the underlying config issue is logged.
_looks_like_provider_id = (
str ( default_model ) . strip ( ) . lower ( ) . replace ( " _ " , " - " ) in _PROVIDER_DISPLAY
or _canonicalise_provider_id ( default_model ) in _PROVIDER_DISPLAY
)
if _looks_like_provider_id :
logger . warning (
" Suspicious model.default value %r — looks like a provider id, "
" not a model id. Skipping picker injection. Check `model.default` "
" in config.yaml. " ,
default_model ,
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
)
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
else :
Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* 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>
2026-06-16 00:16:36 +00:00
all_ids_norm = {
_norm_model_id ( m [ " id " ] )
for g in groups
for bucket_name in ( " models " , " extra_models " )
for m in g . get ( bucket_name , [ ] )
}
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
if _norm_model_id ( default_model ) not in all_ids_norm :
label = _get_label_for_model ( default_model , groups )
target_display = (
_PROVIDER_DISPLAY . get ( active_provider , active_provider or " " ) . lower ( )
if active_provider
else " "
2026-04-12 07:11:41 +00:00
)
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
injected = False
for g in groups :
if target_display and g . get ( " provider " , " " ) . lower ( ) == target_display :
g [ " models " ] . insert ( 0 , { " id " : default_model , " label " : label } )
injected = True
break
if not injected and groups :
groups . append (
{
" provider " : " Default " ,
" provider_id " : active_provider or " default " ,
" models " : [ { " id " : default_model , " label " : label } ] ,
}
)
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
2026-04-28 18:42:52 +00:00
# Post-process: ensure model IDs are globally unique across groups.
# When multiple providers expose the same bare model ID, prefix
# collisions with @provider_id: so the frontend can distinguish them.
_deduplicate_model_ids ( groups )
fix(picker): collapse duplicate provider groups + guard provider-id-as-model.default (closes #1568)
Reporter (Deor, Discord #report-bugs, May 03 2026 14:19 PT, relayed by
@AvidFuturist) saw the Settings → Default Model dropdown rendering the
OpenCode Go provider as TWO separate optgroups: "OpenCode Go" (the
canonical one with all 14 catalog models) and "Opencode_Go" (a phantom
group containing one self-referential entry).
Three structural causes, all in api/config.py:_build_available_models_uncached:
1. **Detection-path id leakage.** The detection block at line ~1980
reads cfg["providers"] keys verbatim. If the user's config has
``providers.opencode_go.api_key`` (underscore variant) AND another
path adds the canonical ``opencode-go`` (e.g. via active_provider),
both end up in detected_providers and the build loop creates two
distinct provider groups with the second labelled via the
``pid.title()`` fallback as ``"Opencode_Go"``.
2. **Injection-block rogue model.** The default-model injection block
at line ~2598 puts ANY ``model.default`` string into the picker as
a fake option. A stray ``model.default: opencode_go`` (provider id
mistakenly used as a model id) surfaces as a phantom model
labelled ``"Opencode GO"``.
3. **Empty-group bleed.** When a non-canonical provider id makes it
into detected_providers but has no entry in _PROVIDER_MODELS, the
build loop creates an optgroup with zero models — pure UI noise.
This PR addresses all three:
- **New `_canonicalise_provider_id()` helper** that folds underscores
to hyphens, lowercases, and applies alias resolution only when the
alias target is itself a canonical id in `_PROVIDER_DISPLAY`. The
last constraint avoids round-tripping ``x-ai`` (canonical) through
the alias table to ``xai`` (which the WebUI doesn't index by).
- **Detection-path canonicalisation.** The cfg["providers"] scan
applies the helper before adding to detected_providers. Same
treatment in the only_show_configured intersection so that mode
doesn't accidentally exclude the canonical id when configured_providers
only contains the underscore-variant key.
- **Post-collection dedup pass** that re-canonicalises every entry in
detected_providers — belt-and-braces against future regressions in
any of the ~25 ``detected_providers.add(...)`` callsites without
auditing each one. Idempotent for already-canonical ids.
- **Provider-id guard on the model.default injection block.** When
the injected value matches a known provider display name or alias
(after underscore/case normalisation), skip the injection and emit
a `logger.warning` instead. Real unknown model ids (newly released
models, custom endpoints) still get injected — only provider-shaped
values are rejected.
- **Empty-group filter at end of build.** Drop optgroups with zero
models. Custom: groups (`provider_id` starts with `custom:`) are
exempt — users may want an empty card visible as a reminder.
Tests
-----
`tests/test_issue1568_duplicate_provider_groups.py` (17 tests):
- TestCanonicaliseProviderId (8): unit tests pinning helper behaviour —
canonical preserved, underscore folded, case folded, aliases
resolved, x-ai not round-tripped, empty input, unknown ids
normalised, idempotence
- TestProviderGroupDedup (4): end-to-end picker behaviour —
underscored providers-key produces ONE group not two (Deor's case),
uppercase providers-key collapsed, aliased keys (z-ai → zai)
collapsed, happy path unchanged
- TestDefaultModelProviderIdGuard (3): provider id as model.default
doesn't inject phantom + WARNING logged; alias as model.default also
caught; legitimate unknown model IDs (forward-compat) still injected
- TestEmptyGroupFilter (2): empty optgroups dropped from picker;
custom: providers exempted from filter
Plus one structural test fix in
`tests/test_issue604_all_providers_model_picker.py:test_cfg_providers_only_adds_known`
— widened the regex window from 500 to 1500 chars so the new
documentation comment block doesn't push `_PROVIDER_MODELS` past the
substring slice. Pre-existing brittle window pattern, not a new issue.
Verification
------------
Live on port 8789 with Deor's exact reproduction config
(`providers.opencode_go.api_key` + `model.provider: opencode-go`):
/api/models groups: 1 (was 2)
Browser <select> optgroups: 1 (was 2)
Total options under "OpenCode Go": 14 (was 14 in real group + 0 in phantom group)
Five-scenario sweep all collapse to ONE provider group:
| Config shape | Pre-fix | Post-fix |
|---|---|---|
| Hyphenated provider + underscored providers-key (Deor's case) | 2 groups | 1 group ✅ |
| Hyphenated provider + UPPERCASE providers-key | 2 groups | 1 group ✅ |
| Aliased providers-key (z-ai resolved to zai) | 2 groups | 1 group ✅ |
| model.default = provider-id (orig #1568 scenario) | 15 models with phantom | 14 models, no phantom ✅ |
| Happy path (canonical-only) | 1 group | 1 group ✅ |
4070 pytest passed (was 4053 → 4070, +17 from this PR).
3 CI runs to follow on push.
QA harness 11/11 passed.
JS unaffected — pure backend fix.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:19 PT)
Relayed by: @AvidFuturist
2026-05-03 22:04:58 +00:00
# Defense-in-depth: drop any optgroup that ended up with zero models
# — those are pure UI noise. A zero-model group typically means a
# detection path added an id that has no static catalog AND the
# live-fetch returned empty (#1568 — the user's
# ``providers.opencode_go`` config-key path produced an empty
# ``Opencode_Go`` group at the end of the picker before this fix).
# Custom providers from ``custom_providers`` config are exempt —
# they may legitimately render with zero entries when the user
# hasn't filled in models yet but wants the card visible.
groups = [
g for g in groups
if g . get ( " models " )
or ( g . get ( " provider_id " ) or " " ) . startswith ( " custom: " )
]
2026-05-21 07:38:03 +00:00
# Sort groups: active provider first, then custom:* providers,
# then providers with configured keys, then the rest alphabetically.
_providers_with_keys : set [ str ] = set ( )
try :
_pool = auth_store . get ( " credential_pool " , { } ) if isinstance ( auth_store , dict ) else { }
if isinstance ( _pool , dict ) :
for _pid in _pool :
_providers_with_keys . add ( _resolve_provider_alias ( str ( _pid ) ) )
except Exception :
pass
try :
_cfg_providers = cfg . get ( " providers " , { } )
if isinstance ( _cfg_providers , dict ) :
for _pk , _pv in _cfg_providers . items ( ) :
if isinstance ( _pv , dict ) and ( _pv . get ( " api_key " ) or _pv . get ( " key_env " ) ) :
_providers_with_keys . add ( _resolve_provider_alias ( str ( _pk ) ) )
except Exception :
pass
def _group_sort_key ( g ) :
pid = g . get ( " provider_id " ) or " "
if pid == active_provider :
return ( 0 , pid )
if pid . startswith ( " custom: " ) :
return ( 1 , pid )
if pid in _providers_with_keys :
return ( 2 , pid )
return ( 3 , pid )
groups . sort ( key = _group_sort_key )
2026-05-17 19:22:06 +00:00
# 12. Include model aliases so the WebUI frontend can resolve them.
model_aliases : dict [ str , str ] = { }
try :
raw_aliases = cfg . get ( " model " , { } ) . get ( " aliases " , { } )
if isinstance ( raw_aliases , dict ) :
model_aliases = { str ( k ) . strip ( ) : str ( v ) . strip ( ) for k , v in raw_aliases . items ( ) if k and v }
except Exception :
pass
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
return {
" active_provider " : active_provider ,
" default_model " : default_model ,
2026-04-30 02:54:07 +00:00
" configured_model_badges " : _build_configured_model_badges ( ) ,
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
" groups " : groups ,
2026-05-17 19:22:06 +00:00
" aliases " : model_aliases ,
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
}
# ── FAST PATH ─────────────────────────────────────────────────────────────
# Mark that a build may be in progress BEFORE acquiring the lock.
# If another thread has already started the cold path, we will wait for
# its result rather than running the cold path concurrently.
should_wait = _cache_build_in_progress
2026-06-27 10:13:16 +00:00
force_refresh_started_at = time . monotonic ( ) if force_refresh else None
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# Check config mtime OUTSIDE the lock so this cheap check doesn't serialize
# concurrent requests. Must come before any config reads in the cold path.
try :
_current_mtime = Path ( _get_config_path ( ) ) . stat ( ) . st_mtime
except OSError :
_current_mtime = 0.0
_cfg_changed = _current_mtime != _cfg_mtime
# Disk load BEFORE lock: ~0.1ms, lets concurrent requests skip entirely.
# Then acquire lock and check memory cache. Cold path runs inside the lock
# so only one thread rebuilds while others wait.
disk_groups = None
2026-06-20 18:21:31 +00:00
stale_disk_groups = None
2026-06-23 16:18:44 +00:00
if _available_models_cache is None and not force_refresh :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
disk_groups = _load_models_cache_from_disk ( )
2026-06-20 18:21:31 +00:00
if disk_groups is None :
stale_disk_groups = _load_stale_models_cache_from_disk ( )
2026-06-23 16:18:44 +00:00
elif force_refresh :
stale_disk_groups = _load_stale_models_cache_from_disk ( )
2026-04-05 15:29:40 +00:00
2026-04-21 00:33:03 +00:00
with _available_models_cache_lock :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# If another thread is already building, wait for its result instead
# of re-entering the cold path (avoids duplicate 10s zai load_pool calls).
if should_wait :
2026-06-27 11:06:57 +00:00
wait_timeout = 60.0
2026-06-27 13:24:44 +00:00
if force_refresh and force_refresh_started_at is not None :
if _LIVE_REBUILD_BUDGET_SECONDS < = 0 :
# The legacy synchronous path is explicitly unbounded. A
# forced refresh follower should keep coalescing behind
# that live rebuild instead of giving up after 60s and
# duplicating it.
wait_timeout = None
else :
wait_timeout = max (
0.0 ,
_LIVE_REBUILD_BUDGET_SECONDS - ( time . monotonic ( ) - force_refresh_started_at ) ,
)
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_cache_build_cv . wait_for (
2026-06-27 11:21:43 +00:00
lambda : not _cache_build_in_progress ,
2026-06-27 11:06:57 +00:00
timeout = wait_timeout
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
)
2026-04-29 04:00:27 +00:00
cached = _get_fresh_memory_models_cache ( time . monotonic ( ) )
2026-06-27 13:24:44 +00:00
if (
cached is not None
and (
not force_refresh
or (
force_refresh_started_at is not None
and _available_models_live_rebuild_ts > = force_refresh_started_at
)
)
) :
2026-04-29 04:00:27 +00:00
return cached
2026-06-27 13:24:44 +00:00
if force_refresh and _LIVE_REBUILD_BUDGET_SECONDS > 0 and _cache_build_in_progress :
if stale_disk_groups is not None :
return copy . deepcopy ( stale_disk_groups )
return copy . deepcopy ( _static_models_catalog_without_live_probes ( ) )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# Reload config if changed
if _cfg_changed :
reload_config ( )
_available_models_cache = None
_available_models_cache_ts = 0.0
2026-06-27 10:36:18 +00:00
_available_models_live_rebuild_ts = 0.0
2026-05-05 15:33:44 +00:00
_available_models_cache_source_fingerprint = None
2026-04-29 04:00:27 +00:00
disk_groups = None
2026-06-20 18:21:31 +00:00
stale_disk_groups = None
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# Serve from memory cache if fresh
now = time . monotonic ( )
2026-06-27 10:13:16 +00:00
cached = _get_fresh_memory_models_cache ( now )
if cached is not None :
if not force_refresh :
return cached
2026-06-27 10:36:18 +00:00
if (
force_refresh_started_at is not None
and _available_models_live_rebuild_ts > = force_refresh_started_at
) :
2026-06-23 16:18:44 +00:00
return cached
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
2026-06-27 10:24:29 +00:00
# A concurrent forced refresh may have started after this caller sampled
# should_wait but before it acquired the lock. Reuse that in-flight build
# instead of launching another one, and preserve this caller's budget.
if (
force_refresh
and force_refresh_started_at is not None
and _cache_build_in_progress
) :
2026-06-27 11:06:57 +00:00
remaining_budget = None
2026-06-27 10:24:29 +00:00
if _LIVE_REBUILD_BUDGET_SECONDS > 0 :
remaining_budget = max (
0.0 ,
_LIVE_REBUILD_BUDGET_SECONDS - ( time . monotonic ( ) - force_refresh_started_at ) ,
)
2026-06-27 11:06:57 +00:00
if remaining_budget is None or remaining_budget > 0 :
2026-06-27 10:24:29 +00:00
_cache_build_cv . wait_for (
lambda : not _cache_build_in_progress ,
timeout = remaining_budget ,
)
cached = _get_fresh_memory_models_cache ( time . monotonic ( ) )
if (
cached is not None
2026-06-27 10:36:18 +00:00
and _available_models_live_rebuild_ts > = force_refresh_started_at
2026-06-27 10:24:29 +00:00
) :
return cached
if _cache_build_in_progress and _LIVE_REBUILD_BUDGET_SECONDS > 0 :
if stale_disk_groups is not None :
return copy . deepcopy ( stale_disk_groups )
return copy . deepcopy ( _static_models_catalog_without_live_probes ( ) )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# Cold path: disk cache hit — use it (fast, no lock contention)
2026-06-23 16:18:44 +00:00
if disk_groups is not None and not force_refresh :
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
_available_models_cache = disk_groups
_available_models_cache_ts = now
2026-05-05 15:33:44 +00:00
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint ( )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
return copy . deepcopy ( disk_groups )
2026-06-09 05:36:18 +00:00
# ── prefer_cache: NEVER run the live provider rebuild ────────────────
# Server-initiated wakeup turns (Option Z) reach here with a cold
# cache (the drain thread fires while idle; the catalog warmed by a
# human's /api/models has expired or was never built). The live
# rebuild does a Copilot token-exchange HTTPS call per the proven
# thread-stack; on this WSL/corp network it stalls the wakeup
# chat/start indefinitely. A wakeup turn does NOT need the full live
# catalog — _resolve_compatible_session_model_state only needs
# default_model/active_provider and trusts the persisted session
# model. Serve a network-free minimal catalog instead and let a later
# human request do the real live rebuild.
if prefer_cache :
# NOTE (Greptile P1): do NOT touch _cache_build_in_progress here.
# This branch never set the flag (only the cold path below does),
# and `should_wait` is sampled outside the lock (line ~4964). A
# concurrent cold-path caller can flip the flag to True after our
# sample but before we acquire the lock; clearing it here would
# prematurely release that rebuild's serialization, waking waiters
# to an empty cache and triggering a second live rebuild. Just
# serve the network-free minimal catalog and leave the flag alone.
return copy . deepcopy ( _minimal_static_models_catalog ( ) )
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
# Cold path: full rebuild — only one thread reaches here at a time
with _cache_build_cv :
_cache_build_in_progress = True
2026-06-09 05:36:18 +00:00
2026-06-11 01:41:24 +00:00
# Capture the active per-request profile (#3957). The live provider
# probe inside the rebuild resolves credentials from os.environ /
# HERMES_HOME and the disk-cache path/fingerprint from the profile TLS;
# the detached worker thread below inherits NEITHER, so it must be
# captured here (on the request thread, where the TLS is valid) and
# re-bound on the worker. Empty / default for single-profile installs.
from contextlib import nullcontext as _nullcontext
_active_profile_name = " "
_prof_env_request = None
_prof_scope_worker = None
try :
from api . profiles import (
get_active_profile_name as _gapn ,
profile_env_for_active_request as _prof_env_request ,
profile_scope_for_detached_worker as _prof_scope_worker ,
)
_active_profile_name = ( _gapn ( ) or " " ) . strip ( )
except Exception :
_prof_env_request = None
_prof_scope_worker = None
2026-06-09 05:36:18 +00:00
# Legacy synchronous (unbounded) rebuild — opt-in via budget<=0.
if _LIVE_REBUILD_BUDGET_SECONDS < = 0 :
try :
2026-06-11 01:41:24 +00:00
# Foreground thread already carries the request-profile TLS;
2026-06-20 07:37:35 +00:00
# apply the mirrored profile env (no-op for default) for the
# live probe because provider_model_ids() still has raw
# os.getenv()/HERMES_HOME readers on this synchronous path.
2026-06-11 01:41:24 +00:00
_sync_scope = (
_prof_env_request ( " models rebuild (sync) " )
if _prof_env_request is not None
else _nullcontext ( )
)
with _sync_scope :
result = _invoke_models_rebuild ( _build_available_models_uncached )
2026-06-09 05:36:18 +00:00
except BaseException :
# Always reset the flag so waiting threads don't block for 60s
with _cache_build_cv :
_cache_build_in_progress = False
_cache_build_cv . notify_all ( )
raise
v0.50.212: model cache perf (~30s→~1ms), session switch UX, cache isolation fix (#1063)
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
2026-04-26 01:24:30 +00:00
with _cache_build_cv :
2026-06-27 10:36:18 +00:00
published_at = time . monotonic ( )
2026-06-09 05:36:18 +00:00
_available_models_cache = result
2026-06-27 10:36:18 +00:00
_available_models_cache_ts = published_at
_available_models_live_rebuild_ts = published_at
2026-06-09 05:36:18 +00:00
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint ( )
2026-06-23 21:53:36 +00:00
try :
_save_models_cache_to_disk ( result )
finally :
with _cache_build_cv :
_cache_build_in_progress = False
_cache_build_cv . notify_all ( )
2026-06-09 05:36:18 +00:00
return copy . deepcopy ( result )
# ── Bounded rebuild (defense-in-depth) ───────────────────────────────
# The live rebuild does a network probe per provider (Copilot token
# exchange over HTTPS, OpenRouter/Nous /models, ...). On a flaky / corp
# / WSL network any single probe can stall for its full per-call
# timeout and, summed across providers, pin a foreground request
# thread for tens of seconds (the wakeup-turn / chat-start hang).
#
# Run the rebuild on a daemon worker; the foreground waits at most
# _LIVE_REBUILD_BUDGET_SECONDS.
#
# WITHIN budget (the normal fast case): the FOREGROUND publishes the
# result synchronously and only then returns — preserving the exact
# pre-existing contract (cache + on-disk file populated by the time
# get_available_models() returns). The worker stays hands-off.
#
# OVER budget (a provider probe is slow/hung): the foreground returns
# the best fallback immediately and the still-running worker publishes
# its result out-of-band when it finally finishes, so the next caller
# gets a warm cache instead of paying the cold rebuild again.
#
# ``_publish_models_result`` / ``box["published"]`` ensure exactly one
# publisher even at the budget boundary (no double write, no lost
# refresh). The worker only touches _cache_build_cv after the
# foreground releases the RLock by returning, so no lock inversion.
build_done = threading . Event ( )
budget_exceeded = threading . Event ( )
publish_lock = threading . Lock ( )
box : dict = { }
def _publish_models_result ( result ) :
global _cache_build_in_progress , _available_models_cache
2026-06-27 10:36:18 +00:00
global _available_models_cache_ts , _available_models_live_rebuild_ts
global _available_models_cache_source_fingerprint
2026-06-09 05:36:18 +00:00
with _cache_build_cv :
2026-06-27 10:36:18 +00:00
published_at = time . monotonic ( )
2026-06-09 05:36:18 +00:00
_available_models_cache = result
2026-06-27 10:36:18 +00:00
_available_models_cache_ts = published_at
_available_models_live_rebuild_ts = published_at
2026-06-09 05:36:18 +00:00
_available_models_cache_source_fingerprint = (
_models_cache_source_fingerprint ( )
)
try :
_save_models_cache_to_disk ( result )
except Exception :
logger . debug ( " models cache disk save failed " , exc_info = True )
2026-06-23 21:53:36 +00:00
finally :
with _cache_build_cv :
_cache_build_in_progress = False
_cache_build_cv . notify_all ( )
2026-06-09 05:36:18 +00:00
def _clear_build_in_progress ( ) :
global _cache_build_in_progress
with _cache_build_cv :
_cache_build_in_progress = False
_cache_build_cv . notify_all ( )
def _claim_publish ( ) - > bool :
""" Return True iff the caller won the right to publish. """
with publish_lock :
if box . get ( " published " ) :
return False
box [ " published " ] = True
return True
def _rebuild_worker ( ) :
2026-06-11 01:41:24 +00:00
# Re-bind the captured per-request profile on THIS worker thread
# (#3957): the daemon inherits neither the request-profile TLS nor
# os.environ, so without this it would probe the default profile's
# credentials and, over budget, publish the rebuilt catalog to the
# DEFAULT profile's disk cache. No-op for the default profile.
_worker_scope = (
_prof_scope_worker ( _active_profile_name , " models rebuild (worker) " )
if _prof_scope_worker is not None
else _nullcontext ( )
)
with _worker_scope :
try :
box [ " result " ] = _invoke_models_rebuild ( _build_available_models_uncached )
except Exception as exc : # noqa: BLE001 — propagated to caller
box [ " error " ] = exc
finally :
build_done . set ( )
# Only publish out-of-band if the foreground already gave up
# (over budget). Within budget the foreground publishes
# synchronously, so the worker must NOT touch the cache.
# NOTE: the publish (and its disk write + fingerprint) runs
# INSIDE this profile scope so the over-budget path writes
# the correct profile's cache file.
if budget_exceeded . is_set ( ) and _claim_publish ( ) :
if " result " in box :
_publish_models_result ( box [ " result " ] )
else :
_clear_build_in_progress ( )
2026-06-09 05:36:18 +00:00
_worker = threading . Thread (
target = _rebuild_worker ,
name = " models-catalog-rebuild " ,
daemon = True ,
)
_worker . start ( )
if build_done . wait ( timeout = _LIVE_REBUILD_BUDGET_SECONDS ) :
# Build finished within budget — foreground publishes
# synchronously, exactly like the legacy path.
if " error " in box :
_clear_build_in_progress ( )
raise box [ " error " ]
if _claim_publish ( ) :
_publish_models_result ( box [ " result " ] )
return copy . deepcopy ( box [ " result " ] )
# Budget elapsed. Mark it so the worker knows it owns out-of-band
# publication. Handle the tiny race where the build completed between
# wait() returning False and here: if so, still publish synchronously
# so this caller honours the cache contract.
budget_exceeded . set ( )
if build_done . is_set ( ) and " error " not in box and " result " in box :
if _claim_publish ( ) :
_publish_models_result ( box [ " result " ] )
return copy . deepcopy ( box [ " result " ] )
# Genuinely slow/hung probe: serve the best fallback now; the worker
# keeps going and refreshes the cache for the next caller.
# Rate-limit the warning per Q-2979-A3 — see _should_warn_budget; a
# sustained budget breach demotes to info after the first emit in
# each cooldown window so log volume stays bounded.
_budget_log_msg = (
" live provider-catalog rebuild exceeded %.1f s budget — serving "
" fallback, refreshing catalog out-of-band "
)
if _should_warn_budget ( " live_rebuild_budget_exceeded " ) :
logger . warning ( _budget_log_msg , _LIVE_REBUILD_BUDGET_SECONDS )
else :
logger . info ( _budget_log_msg , _LIVE_REBUILD_BUDGET_SECONDS )
2026-06-20 18:21:31 +00:00
# ``stale_disk_groups`` is shape-valid but failed the strict metadata
# checks required for authoritative cold-path use. It was read before
# acquiring _available_models_cache_lock so this over-budget fallback
# does not extend the lock hold while the worker is ready to publish.
if stale_disk_groups is not None :
return copy . deepcopy ( stale_disk_groups )
2026-06-12 11:49:29 +00:00
return copy . deepcopy ( _static_models_catalog_without_live_probes ( ) )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
2026-06-23 16:18:44 +00:00
def _models_cache_file_age_seconds ( cache_path : Path , now : float ) - > float | None :
try :
return max ( 0.0 , now - cache_path . stat ( ) . st_mtime )
except OSError :
return None
def get_available_models_for_session_visit ( ) - > dict :
""" Return /api/models with a short session-visit freshness horizon. """
global _available_models_cache , _available_models_cache_ts , _available_models_cache_source_fingerprint
cache_path = _get_models_cache_path ( )
cache_age = _models_cache_file_age_seconds ( cache_path , time . time ( ) )
disk_cached = None
if cache_age is not None and cache_age < _SESSION_VISIT_MODELS_FRESHNESS_SECONDS :
now_mono = time . monotonic ( )
with _available_models_cache_lock :
cached = _get_fresh_memory_models_cache ( now_mono )
if cached is not None :
return cached
disk_cached = _load_models_cache_from_disk ( )
if disk_cached is not None :
with _available_models_cache_lock :
2026-06-23 21:40:09 +00:00
cached = _get_fresh_memory_models_cache ( time . monotonic ( ) )
if cached is not None :
return cached
2026-06-23 16:18:44 +00:00
_available_models_cache = copy . deepcopy ( disk_cached )
_available_models_cache_ts = time . monotonic ( )
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint ( )
return copy . deepcopy ( disk_cached )
stale_cached = disk_cached or _load_stale_models_cache_from_disk ( )
try :
return get_available_models ( force_refresh = True )
except Exception :
logger . debug ( " session-visit models refresh failed " , exc_info = True )
if stale_cached is not None :
return copy . deepcopy ( stale_cached )
return get_available_models ( prefer_cache = True )
2026-03-31 03:40:19 +00:00
# ── Static file path ─────────────────────────────────────────────────────────
2026-04-12 07:11:41 +00:00
_INDEX_HTML_PATH = REPO_ROOT / " static " / " index.html "
2026-03-31 03:40:19 +00:00
# ── Thread synchronisation ───────────────────────────────────────────────────
2026-04-12 07:11:41 +00:00
LOCK = threading . Lock ( )
fix(webui): bound in-memory SESSIONS cache with lazy reload (#4765)
Root cause of the silent-crash-after-hours cluster (#4765/#2233/#4633): the
global in-memory SESSIONS LRU evicted with a blind popitem(last=False), which
could drop an actively streaming or not-yet-persisted session (data loss) and
was capped only via an env var. On long-running installs the effective result
was unbounded RAM growth until segfault.
- Add _session_is_evictable(): a session is evictable ONLY when it is not
streaming (no active_stream_id), has no in-flight turn (no pending_user_message
/ pending_started_at), and its full state is proven on disk (sidecar
message_count >= in-memory count; metadata-only stubs and zero-message shells
are trivially safe).
- Add _evict_sessions_over_cap(): replaces all 8 blind popitem loops across
models.py, routes.py, streaming.py. Walks the LRU oldest-first and removes only
provably-safe entries; never acquires LOCK/stream locks itself (caller holds
LOCK) so no lock-ordering deadlock. May briefly exceed the cap rather than ever
evict an active/unsaved session.
- Make the cap configurable via config.yaml webui.sessions_cache_max
(get_sessions_cache_max()); precedence config.yaml -> HERMES_WEBUI_SESSIONS_MAX
(legacy) -> DEFAULT_SESSIONS_CACHE_MAX=300. No new HERMES_* env var. Invalid or
<1 values fall back so a typo can never disable the bound.
- Evicted sessions lazily reload from their JSON sidecar via the existing
get_session() accessor; no call sites changed. _index.json sidebar behavior
unchanged (the sidebar reads the index, not SESSIONS).
- Add tests/test_issue4765_sessions_lru_eviction.py (8 tests): eviction past
cap, active/streaming never evicted, unsaved/stale-tail never evicted, lazy
reload with identical content, and no-data-loss under heavy churn.
- README: document the config.yaml key + safety semantics.
Fixes #4765.
2026-07-01 18:14:39 +00:00
# Max compact Session objects held in the in-memory LRU (issue #3506, #4765).
# Lighter than the agent cache (no live agent runtime), but still bounded so a
# long-running self-hosted install cannot accumulate every session it ever
# touched in RAM and eventually segfault (the #4765/#2233/#4633 crash cluster).
#
# Precedence for the effective cap is resolved by get_sessions_cache_max():
# 1. config.yaml webui.sessions_cache_max (preferred, no new env var)
# 2. HERMES_WEBUI_SESSIONS_MAX env var (legacy operator override)
# 3. DEFAULT_SESSIONS_CACHE_MAX (sane bounded default)
DEFAULT_SESSIONS_CACHE_MAX = 300
SESSIONS_MAX = _env_int ( " HERMES_WEBUI_SESSIONS_MAX " , DEFAULT_SESSIONS_CACHE_MAX )
def get_sessions_cache_max ( config_data : dict | None = None ) - > int :
""" Return the effective in-memory SESSIONS cache cap (issue #4765).
The bound is configurable through ` ` webui . sessions_cache_max ` ` in
` ` config . yaml ` ` so operators of large self - hosted installs can size the
cache without editing source or adding a new ` ` HERMES_ * ` ` env var ( this
project forbids new env vars for non - secret config ) . A missing , empty ,
non - numeric , or below - 1 value falls back to the legacy
` ` HERMES_WEBUI_SESSIONS_MAX ` ` env override , then to
` ` DEFAULT_SESSIONS_CACHE_MAX ` ` — a typo can never disable the bound and
reintroduce unbounded memory growth .
"""
active_cfg = config_data if isinstance ( config_data , dict ) else get_config ( )
webui_cfg = active_cfg . get ( " webui " , { } ) if isinstance ( active_cfg , dict ) else { }
if isinstance ( webui_cfg , dict ) :
raw = webui_cfg . get ( " sessions_cache_max " )
if raw is not None :
try :
value = int ( raw )
except ( TypeError , ValueError ) :
value = None
if value is not None and value > = 1 :
return value
# config.yaml did not specify a valid cap: honor the legacy env override
# (already parsed into SESSIONS_MAX) and finally the hardened default.
if isinstance ( SESSIONS_MAX , int ) and SESSIONS_MAX > = 1 :
return SESSIONS_MAX
return DEFAULT_SESSIONS_CACHE_MAX
2026-04-12 07:11:41 +00:00
CHAT_LOCK = threading . Lock ( )
2026-05-04 05:43:11 +00:00
class StreamChannel :
""" Broadcast SSE events to every connected browser tab for a stream.
While no tab is connected , events are buffered so the first / reconnected
subscriber still receives the stream tail that arrived during the gap .
Once one or more subscribers are attached , new events are broadcast to all
of them instead of being consumed destructively by a single queue reader .
"""
def __init__ ( self ) :
self . _lock = threading . Lock ( )
self . _subscribers : list [ queue . Queue ] = [ ]
self . _offline_buffer : list [ tuple [ str , object ] ] = [ ]
2026-06-06 19:12:37 +00:00
self . _last_event_id : str | None = None
2026-05-04 05:43:11 +00:00
def subscribe ( self ) - > queue . Queue :
2026-06-06 19:12:37 +00:00
q , _snapshot = self . subscribe_with_snapshot ( )
return q
def subscribe_with_snapshot ( self ) - > tuple [ queue . Queue , dict [ str , object ] ] :
2026-05-04 05:43:11 +00:00
q : queue . Queue = queue . Queue ( )
with self . _lock :
2026-05-04 15:45:41 +00:00
# Replay buffered events to the new subscriber INSIDE the lock so a
# concurrent put_nowait() can't broadcast a newer event before we
# finish replaying the older buffered tail. queue.Queue.put_nowait
# is non-blocking on an unbounded queue, so holding the lock here
# is safe. Per Opus advisor on stage-292.
for item in self . _offline_buffer :
q . put_nowait ( item )
2026-06-06 19:12:37 +00:00
snapshot = {
" offline_buffered_events " : len ( self . _offline_buffer ) ,
" last_event_id " : self . _last_event_id ,
}
2026-05-04 05:43:11 +00:00
self . _subscribers . append ( q )
2026-06-06 19:12:37 +00:00
return q , snapshot
2026-05-04 05:43:11 +00:00
def unsubscribe ( self , q : queue . Queue ) - > None :
with self . _lock :
try :
self . _subscribers . remove ( q )
except ValueError :
pass
2026-06-06 19:12:37 +00:00
def note_last_event_id ( self , event_id : str | None ) - > None :
""" Record the latest journal event id without changing the queue shape. """
if not event_id :
return
with self . _lock :
self . _last_event_id = event_id
def put_nowait ( self , item : tuple [ str , object ] | tuple [ str , object , str | None ] ) - > None :
event_id = item [ 2 ] if len ( item ) > = 3 else None
2026-05-04 05:43:11 +00:00
with self . _lock :
2026-06-06 19:12:37 +00:00
if event_id :
self . _last_event_id = event_id
2026-05-04 05:43:11 +00:00
subscribers = list ( self . _subscribers )
if not subscribers :
self . _offline_buffer . append ( item )
return
self . _offline_buffer . clear ( )
for q in subscribers :
q . put_nowait ( item )
2026-06-06 19:12:37 +00:00
def diagnostic_snapshot ( self ) - > dict [ str , object ] :
2026-05-18 12:13:30 +00:00
""" Return non-sensitive stream observation counters for health checks. """
with self . _lock :
return {
" subscriber_count " : len ( self . _subscribers ) ,
" offline_buffered_events " : len ( self . _offline_buffer ) ,
}
2026-05-04 05:43:11 +00:00
def create_stream_channel ( ) - > StreamChannel :
return StreamChannel ( )
2026-04-12 07:11:41 +00:00
STREAMS : dict = { }
STREAMS_LOCK = threading . Lock ( )
2026-06-29 17:09:19 +00:00
# stream_id -> session_id owner, populated synchronously before worker startup so
# stream-id authorization does not depend on worker lifecycle registration.
STREAM_SESSION_OWNERS : dict = { }
STREAM_SESSION_OWNERS_LOCK = threading . Lock ( )
2026-03-31 03:40:19 +00:00
CANCEL_FLAGS : dict = { }
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes #240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
AGENT_INSTANCES : dict = { } # stream_id -> AIAgent instance for interrupt propagation
2026-04-23 18:16:59 +00:00
STREAM_PARTIAL_TEXT : dict = { } # stream_id -> partial assistant text accumulated during streaming
2026-04-30 23:24:29 +00:00
STREAM_REASONING_TEXT : dict = { } # stream_id -> reasoning trace accumulated during streaming (#1361 §A)
STREAM_LIVE_TOOL_CALLS : dict = { } # stream_id -> live tool calls accumulated during streaming (#1361 §B)
2026-05-09 07:08:13 +00:00
STREAM_GOAL_RELATED : dict = { } # stream_id -> bool: only evaluate goal for goal-related turns (#1932)
stage-364: Opus-caught live SSE event_id fix (side-channel approach)
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
2026-05-16 03:58:31 +00:00
STREAM_LAST_EVENT_ID : dict = { } # stream_id -> latest journal event_id for `id:` field on live SSE frames (stage-364)
2026-05-09 07:08:13 +00:00
PENDING_GOAL_CONTINUATION : set = set ( ) # session_ids awaiting a goal continuation turn (#1932)
2026-05-11 00:15:00 +00:00
2026-06-29 17:09:19 +00:00
def register_stream_owner ( stream_id : str , session_id : str ) - > None :
""" Record the session that owns a stream before worker startup. """
stream_id = str ( stream_id or " " ) . strip ( )
session_id = str ( session_id or " " ) . strip ( )
if not stream_id or not session_id :
return
with STREAM_SESSION_OWNERS_LOCK :
STREAM_SESSION_OWNERS [ stream_id ] = session_id
def stream_owner_session_id ( stream_id : str ) - > str | None :
""" Return the synchronously-recorded owner session for a stream, if any. """
stream_id = str ( stream_id or " " ) . strip ( )
if not stream_id :
return None
with STREAM_SESSION_OWNERS_LOCK :
owner = STREAM_SESSION_OWNERS . get ( stream_id )
owner = str ( owner or " " ) . strip ( )
return owner or None
def unregister_stream_owner ( stream_id : str ) - > None :
""" Forget the pre-worker stream owner once the stream has torn down. """
stream_id = str ( stream_id or " " ) . strip ( )
if not stream_id :
return
with STREAM_SESSION_OWNERS_LOCK :
STREAM_SESSION_OWNERS . pop ( stream_id , None )
2026-06-16 05:25:24 +00:00
# ── Gateway capability cache ─────────────────────────────────────────────────
# Probes /v1/capabilities once per base_url/api-key pair and caches the result
# for 60 s so guarded-turn routing decisions do not add latency on every chat
# turn.
_GATEWAY_CAPS_CACHE : dict [ tuple [ str , str ] , dict ] = { }
_GATEWAY_CAPS_LOCK = threading . Lock ( )
_GATEWAY_CAPS_TTL_S : float = 60.0
2026-06-28 22:29:37 +00:00
def _gateway_caps_probe_timed_out ( exc : BaseException ) - > bool :
""" Keep slow capability probes on the legacy reachable-but-unsupported path. """
reason = exc . reason if isinstance ( exc , urllib . error . URLError ) else exc
if isinstance ( reason , ( TimeoutError , socket . timeout ) ) :
return True
reason_text = str ( reason ) . lower ( )
return " timed out " in reason_text or " timeout " in reason_text
2026-06-16 05:25:24 +00:00
def get_gateway_caps ( base_url : str , api_key : str = " " ) - > dict :
""" Return cached gateway capability flags, probing /v1/capabilities if stale. """
base_url = str ( base_url or " " ) . rstrip ( " / " )
cache_key = ( base_url , str ( api_key or " " ) )
now = time . time ( )
probe_started_at = now
with _GATEWAY_CAPS_LOCK :
cached = _GATEWAY_CAPS_CACHE . get ( cache_key )
if cached and now - cached . get ( " fetched_at " , 0 ) < _GATEWAY_CAPS_TTL_S :
return cached
2026-06-28 21:53:37 +00:00
caps = {
" approval_events " : False ,
" run_approval_response " : False ,
" capabilities_reachable " : False ,
" probe_error " : None ,
" fetched_at " : 0.0 ,
}
2026-06-16 05:25:24 +00:00
try :
headers = { }
if api_key :
headers [ " Authorization " ] = f " Bearer { api_key } "
req = urllib . request . Request ( f " { base_url } /v1/capabilities " , headers = headers , method = " GET " )
with urllib . request . urlopen ( req , timeout = 3 ) as resp :
2026-06-28 22:29:37 +00:00
caps [ " capabilities_reachable " ] = True
2026-06-16 05:25:24 +00:00
body = json . loads ( resp . read ( 65536 ) )
features = body . get ( " features " ) if isinstance ( body , dict ) else { }
if not isinstance ( features , dict ) :
features = { }
caps [ " approval_events " ] = bool ( features . get ( " approval_events " ) )
caps [ " run_approval_response " ] = bool ( features . get ( " run_approval_response " ) )
2026-06-28 22:18:47 +00:00
except urllib . error . HTTPError as exc :
2026-06-28 22:29:37 +00:00
caps [ " capabilities_reachable " ] = True
caps [ " probe_error " ] = f " { type ( exc ) . __name__ } : { exc } "
except urllib . error . URLError as exc :
if _gateway_caps_probe_timed_out ( exc ) :
2026-06-28 22:18:47 +00:00
caps [ " capabilities_reachable " ] = True
caps [ " probe_error " ] = f " { type ( exc ) . __name__ } : { exc } "
2026-06-28 22:29:37 +00:00
except ( TimeoutError , socket . timeout ) as exc :
caps [ " capabilities_reachable " ] = True
caps [ " probe_error " ] = f " { type ( exc ) . __name__ } : { exc } "
except OSError as exc :
caps [ " probe_error " ] = f " { type ( exc ) . __name__ } : { exc } "
2026-06-28 21:53:37 +00:00
except Exception as exc :
caps [ " probe_error " ] = f " { type ( exc ) . __name__ } : { exc } "
2026-06-16 05:25:24 +00:00
with _GATEWAY_CAPS_LOCK :
current = _GATEWAY_CAPS_CACHE . get ( cache_key )
if current and current . get ( " fetched_at " , 0 ) > probe_started_at :
return current
caps [ " fetched_at " ] = time . time ( )
_GATEWAY_CAPS_CACHE [ cache_key ] = caps
return caps
2026-06-28 21:53:37 +00:00
def gateway_approval_unavailable_reason ( base_url : str , api_key : str = " " ) - > str | None :
""" Return why approval support is unavailable, if it is unavailable. """
caps = get_gateway_caps ( base_url , api_key )
if bool ( caps . get ( " approval_events " ) and caps . get ( " run_approval_response " ) ) :
return None
if not caps . get ( " capabilities_reachable " ) :
return " unreachable "
return " unsupported "
2026-06-16 05:25:24 +00:00
def gateway_supports_approval ( base_url : str , api_key : str = " " ) - > bool :
""" True only when the gateway advertises both approval_events and run_approval_response. """
caps = get_gateway_caps ( base_url , api_key )
return bool ( caps . get ( " approval_events " ) and caps . get ( " run_approval_response " ) )
def invalidate_gateway_caps ( base_url : str | None = None ) - > None :
""" Evict capability cache for base_url, or all entries when base_url is None. """
with _GATEWAY_CAPS_LOCK :
if base_url is None :
_GATEWAY_CAPS_CACHE . clear ( )
else :
normalized = str ( base_url or " " ) . rstrip ( " / " )
for cache_key in [ key for key in _GATEWAY_CAPS_CACHE if key [ 0 ] == normalized ] :
_GATEWAY_CAPS_CACHE . pop ( cache_key , None )
2026-06-09 05:36:18 +00:00
# ── notify_on_complete agent-wakeup wiring ─────────────────────────────────
# When terminal(notify_on_complete=true, background=true) fires, the process
# registry pushes a completion event onto tools.process_registry.completion_queue.
# A drain task spawned at WebUI startup (api/background_process.py) reads that
# queue and emits an SSE `process_complete` event to the matching session.
# PROCESS_SESSION_INDEX maps the per-process "session_key" (set in the spawned
# subprocess via HERMES_SESSION_KEY) back to the WebUI session_id that owns it,
# so the drain task can route the event to the right SSE channel.
# PENDING_BG_TASK_COMPLETIONS mirrors PENDING_GOAL_CONTINUATION: server-side
# marker discarded atomically by routes.py when the frontend re-POSTs the
# wakeup_prompt as the next user turn. (process_complete event, agent wakeup fix)
PROCESS_SESSION_INDEX : dict = { } # process_registry session_key -> WebUI session_id
PROCESS_SESSION_INDEX_LOCK = threading . Lock ( )
PENDING_BG_TASK_COMPLETIONS : set = set ( ) # session_ids awaiting a process_complete wakeup turn
BG_TASK_COMPLETE_EVENTS_SEEN : dict = { } # session_id -> set[process_id] for idempotency
BG_TASK_COMPLETE_EVENTS_SEEN_LOCK = threading . Lock ( )
# Defer-path fix (fast-bg-task wakeup race): when a completion arrives while a
# turn is active, Option Z's drain branch CANNOT start a turn (would 409). The
# pre-existing PENDING_BG_TASK_COMPLETIONS marker was a bare session_id flag —
# the wakeup_prompt was DISCARDED, and the only consumer (PR #2279 next-turn
# drain) reads completion_queue, which the Option Z drain thread already
# emptied. So for an autonomous agent (no next user turn) the deferred wakeup
# was lost forever. DEFERRED_PROCESS_WAKEUPS persists the actual prompt(s) so a
# turn-teardown idle-hook (api/streaming) can redeliver them once the session
# goes idle — symmetric with the idle branch (idle now → fire now; busy now →
# fire at turn-end). Atomic claim (pop under lock) guarantees single delivery:
# whoever claims first (teardown hook OR next-turn drain) fires; the other
# finds nothing → no double-fire, no wakeup loop.
DEFERRED_PROCESS_WAKEUPS : dict = { } # session_id -> list[{"process_id", "wakeup_prompt"}]
DEFERRED_PROCESS_WAKEUPS_LOCK = threading . Lock ( )
# ── Persistent per-session SSE channel (Option X) ──────────────────────────
# A long-lived SSE channel scoped to a WebUI session_id rather than a single
# agent turn (stream_id). Subscribed to by the frontend on session mount,
# torn down on session unmount, and refcounted across tabs. Used to deliver
# events (currently process_complete) that fire while no agent turn is
# active — bridging the gap that PR #2242 + #2279 left when STREAMS has
# already been torn down. The registry lives in api.background_process; this
# constant is the idle-cap before the reaper collects an unsubscribed
# channel. 4h is a defensive ceiling against zombie connections; the
# subscribers-empty grace path (60s) handles ordinary tab-close traffic.
SESSION_CHANNEL_IDLE_TTL_SECS : int = 14400 # 4 hours
SESSION_CHANNEL_SUBSCRIBER_GRACE_SECS : int = 60 # subscribers-empty grace
2026-05-11 00:15:00 +00:00
# Active agent-run registry. This intentionally tracks worker lifecycle rather
# than SSE lifecycle: cancel/reconnect may remove STREAMS while the worker is
# still unwinding, blocked in a provider call, or waiting for delegated work.
ACTIVE_RUNS : dict = { }
ACTIVE_RUNS_LOCK = threading . Lock ( )
LAST_RUN_FINISHED_AT : float | None = None
2026-03-31 03:40:19 +00:00
SERVER_START_TIME = time . time ( )
2026-05-11 00:15:00 +00:00
def register_active_run ( stream_id : str , * * metadata ) - > None :
""" Mark a WebUI agent worker as alive until its outer finally exits. """
if not stream_id :
return
now = time . time ( )
entry = dict ( metadata or { } )
entry . setdefault ( " stream_id " , stream_id )
entry . setdefault ( " started_at " , now )
entry . setdefault ( " phase " , " running " )
with ACTIVE_RUNS_LOCK :
ACTIVE_RUNS [ stream_id ] = entry
def update_active_run ( stream_id : str , * * metadata ) - > None :
""" Update active-run metadata without creating a new run implicitly. """
if not stream_id :
return
with ACTIVE_RUNS_LOCK :
entry = ACTIVE_RUNS . get ( stream_id )
if entry is not None :
entry . update ( metadata )
def unregister_active_run ( stream_id : str ) - > None :
""" Remove a worker from the active-run registry and record idle start. """
if not stream_id :
return
global LAST_RUN_FINISHED_AT
with ACTIVE_RUNS_LOCK :
ACTIVE_RUNS . pop ( stream_id , None )
LAST_RUN_FINISHED_AT = time . time ( )
2026-06-29 17:09:19 +00:00
unregister_stream_owner ( stream_id )
2026-05-11 00:15:00 +00:00
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
# Agent cache: reuse AIAgent across messages in the same WebUI session so that
# _user_turn_count survives between turns. This mirrors the gateway's
# _agent_cache pattern and is required for injectionFrequency: "first-turn".
2026-04-29 12:08:12 +00:00
# LRU cache with size limit to prevent memory bloat.
# All cache operations (get, set, move_to_end, popitem) are protected by
# SESSION_AGENT_CACHE_LOCK for thread safety in multi-threaded ASGI servers.
2026-04-29 09:35:12 +00:00
import collections
SESSION_AGENT_CACHE : collections . OrderedDict = collections . OrderedDict ( ) # LRU cache
Release v0.51.256 — Release HX (stage-r4) (#3596)
## 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>
2026-06-04 19:28:06 +00:00
# Each cached agent pins a full conversation transcript in RAM, so this cap is
# the dominant lever on WebUI resident memory (issue #3506). The default is kept
# deliberately modest -- large/long sessions can each weigh tens of MB, so 50
# live agents could pin >1 GB on a heavily multiplexed install. Operators can
# tune it via HERMES_WEBUI_AGENT_CACHE_MAX without editing source.
SESSION_AGENT_CACHE_MAX = _env_int ( " HERMES_WEBUI_AGENT_CACHE_MAX " , 25 )
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
SESSION_AGENT_CACHE_LOCK = threading . Lock ( )
def _evict_session_agent ( session_id : str ) - > None :
2026-05-17 09:30:06 +00:00
""" Remove a cached agent for a session (on delete, clear, or model switch).
Attempts a lifecycle commit before dropping the agent handle so that
batch - extraction memory providers can extract any pending work . If the
commit fails or there is uncommitted work with no successful commit , the
lifecycle entry is preserved ( not unregistered ) so a future commit can
retry .
"""
agent = None
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
with SESSION_AGENT_CACHE_LOCK :
2026-05-17 09:30:06 +00:00
entry = SESSION_AGENT_CACHE . pop ( session_id , None )
if entry is not None :
agent = entry [ 0 ] if isinstance ( entry , tuple ) else None
if agent is None :
return
2026-06-28 19:36:59 +00:00
# A live run for this session may still hold this agent's _session_db (the
# worker assigns agent._session_db at run start). Never close it out from
# under an in-flight turn — ACTIVE_RUNS is the authoritative liveness signal
# (mirrors the worker's own LRU-eviction guard in streaming.py). When a run
# is live we still drop the cache handle above (harmless — the worker holds
# a local ref), but skip the lifecycle commit + _session_db.close() so the
# running turn can finish persisting. Hardens /clear + model-switch eviction
# too, not just truncate (#5096 Bug D).
_run_active = False
try :
with ACTIVE_RUNS_LOCK :
for _entry in ( ACTIVE_RUNS or { } ) . values ( ) :
if ( _entry or { } ) . get ( " session_id " ) == session_id :
_run_active = True
break
except Exception :
_run_active = False
if _run_active :
return
2026-05-17 09:30:06 +00:00
should_close = True
try :
Release v0.51.256 — Release HX (stage-r4) (#3596)
## 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>
2026-06-04 19:28:06 +00:00
from api . session_lifecycle import commit_session_memory , discard_session , has_uncommitted_work , unregister_agent
2026-05-17 09:30:06 +00:00
if has_uncommitted_work ( session_id ) :
commit_session_memory ( session_id , agent = agent , wait = True )
if not has_uncommitted_work ( session_id ) :
unregister_agent ( session_id )
Release v0.51.256 — Release HX (stage-r4) (#3596)
## 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>
2026-06-04 19:28:06 +00:00
# Bound the lifecycle dict: drop the entry now that the session has
# no uncommitted work and the agent handle is gone (issue #3506).
discard_session ( session_id )
2026-05-17 09:30:06 +00:00
else :
should_close = False
except Exception :
should_close = False
logger . debug ( " Lifecycle commit on eviction failed for %s " , session_id , exc_info = True )
if should_close and getattr ( agent , ' _session_db ' , None ) is not None :
try :
agent . _session_db . close ( )
except Exception :
logger . debug ( " Failed to close _session_db on eviction for %s " , session_id , exc_info = True )
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
2026-03-31 03:40:19 +00:00
# ── Thread-local env context ─────────────────────────────────────────────────
2026-06-22 20:18:01 +00:00
# (_thread_ctx + _thread_local_env_value are defined near the top of this module,
# above the config-file section, so _expand_env_vars can reference them at the
# import-time reload_config() without a forward-reference NameError.)
2026-06-20 06:24:07 +00:00
2026-03-31 03:40:19 +00:00
def _set_thread_env ( * * kwargs ) :
_thread_ctx . env = kwargs
2026-04-12 07:11:41 +00:00
2026-03-31 03:40:19 +00:00
def _clear_thread_env ( ) :
_thread_ctx . env = { }
2026-04-12 07:11:41 +00:00
2026-03-31 03:40:19 +00:00
# ── Per-session agent locks ───────────────────────────────────────────────────
SESSION_AGENT_LOCKS : dict = { }
2026-04-12 07:11:41 +00:00
SESSION_AGENT_LOCKS_LOCK = threading . Lock ( )
2026-03-31 03:40:19 +00:00
def _get_session_agent_lock ( session_id : str ) - > threading . Lock :
2026-04-23 21:25:43 +00:00
""" Return the per-session Lock used to serialize all Session mutations.
Lock lifecycle invariant :
- A Lock is created lazily on first access and lives in SESSION_AGENT_LOCKS
for the lifetime of the session .
- The entry is pruned in / api / session / delete ( under SESSION_AGENT_LOCKS_LOCK )
so deleted sessions don ' t leak a Lock forever.
- During context compression the agent may rotate session_id . The
streaming thread migrates the lock entry atomically under
SESSION_AGENT_LOCKS_LOCK : it aliases the new session_id to the * same *
Lock object and pops the old - id entry ( see streaming . py compression
block ) . This ensures that subsequent callers using the new ID still
acquire the same Lock , while the old - id entry is removed to prevent a
leak . The streaming thread already holds the Lock during this
migration , so the reference stays alive even after the dict entry is
removed .
- Lock contract : hold for the in - memory mutation + s . save ( ) only ; never
across network I / O ( LLM calls , HTTP requests ) .
"""
2026-03-31 03:40:19 +00:00
with SESSION_AGENT_LOCKS_LOCK :
if session_id not in SESSION_AGENT_LOCKS :
SESSION_AGENT_LOCKS [ session_id ] = threading . Lock ( )
return SESSION_AGENT_LOCKS [ session_id ]
2026-04-12 07:11:41 +00:00
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
# ── Settings persistence ─────────────────────────────────────────────────────
_SETTINGS_DEFAULTS = {
2026-04-12 07:11:41 +00:00
" default_workspace " : str ( DEFAULT_WORKSPACE ) ,
" onboarding_completed " : False ,
2026-06-26 15:42:50 +00:00
" send_key " : " enter " , # 'enter', 'ctrl+enter', or 'shift+enter'
2026-04-12 07:11:41 +00:00
" show_token_usage " : False , # show input/output token badge below assistant messages
2026-05-16 23:05:09 +00:00
" show_quota_chip " : False , # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media)
2026-06-10 23:46:39 +00:00
" show_conversation_outline " : False , # show opt-in desktop jump-to-question outline panel
2026-06-28 22:47:42 +00:00
" show_busy_placeholder_hint " : False , # opt-in busy composer placeholder hint
2026-05-21 17:43:48 +00:00
" hide_empty_state_suggestions " : False , # hide the default new-chat suggestion buttons
2026-06-17 03:41:47 +00:00
" virtualize_transcript " : False , # #4343: virtualize long (>80 msg) transcripts. EXPERIMENTAL, opt-IN (default OFF). Was opt-out/default-on in #4325 but caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation) — flipped off for everyone in #4343; re-enabling requires an explicit opt-in (see virtualize_transcript_optin migration in load_settings).
" virtualize_transcript_optin " : False , # #4343 migration marker: True only once the user explicitly enables virtualize_transcript AFTER the default-off flip. A stored virtualize_transcript=True WITHOUT this marker is a stale pre-flip value and is reset to False on load (force-off-for-everyone migration).
2026-05-04 19:08:07 +00:00
" show_tps " : False , # show tokens-per-second chip in assistant message headers
2026-05-11 19:13:26 +00:00
" fade_text_effect " : False , # animate newly streamed words with a lightweight fade-in effect
Release ON: show_cli_sessions default-on for new installs (#3988) + symlink delete/rename guard (#4217) (#4230)
* 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>
2026-06-15 06:06:22 +00:00
" show_cli_sessions " : True , # merge CLI/TUI/messaging sessions from state.db into the sidebar by default (#3988); established installs are grandfathered OFF by the load_settings backfill
2026-07-02 01:40:19 +00:00
" show_claude_code_sessions " : True , # allow filtering Claude Code rows without hiding other imported sources
2026-06-06 00:48:27 +00:00
" show_cron_sessions " : False , # surface cron sessions in the sidebar (subordinate to show_cli_sessions)
2026-06-29 22:40:52 +00:00
" show_webhook_sessions " : False , # surface webhook sessions in the sidebar (subordinate to show_cli_sessions)
2026-05-15 06:17:40 +00:00
" show_previous_messaging_sessions " : False , # show older Telegram/Discord/etc. reset segments
2026-04-12 07:11:41 +00:00
" sync_to_insights " : False , # mirror WebUI token usage to state.db for /insights
" check_for_updates " : True , # check if webui/agent repos are behind upstream
2026-05-24 14:20:31 +00:00
" ignore_agent_updates " : False , # keep WebUI update notices but suppress Agent update checks
2026-05-13 21:22:26 +00:00
" whats_new_summary_enabled " : False , # show an LLM-written What's New summary before diff links
2026-07-02 19:43:24 +00:00
" tts_enabled " : False ,
" tts_auto_read " : False ,
" tts_engine " : " browser " ,
" tts_voice " : " " ,
" tts_rate " : 1.0 ,
" tts_pitch " : 1.0 ,
" voice_mode_button " : False ,
" voice_continuous " : False ,
" voice_silence_ms " : 1800 ,
" raw_audio_mode " : False ,
2026-04-30 02:59:26 +00:00
" theme " : " dark " , # light | dark | system
Release v0.51.242 — Release HJ (stage-q14) (#3500)
## 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>
2026-06-03 23:46:53 +00:00
" skin " : " default " , # accent color skin: default | ares | mono | graphite | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous
2026-05-14 02:59:52 +00:00
" font_size " : " default " , # small | default | large | xlarge
2026-05-08 18:31:43 +00:00
" session_jump_buttons " : False , # show Start/End transcript jump pills
2026-06-21 00:03:02 +00:00
" render_user_markdown " : False , # opt-in: render full markdown in user messages (#3870)
2026-06-30 03:52:57 +00:00
" large_text_paste_as_attachment " : True , # convert very large composer text pastes into .md attachments by default
2026-06-30 19:49:07 +00:00
" project_quick_create_buttons " : False , # opt-in: show per-project "+" quick-create buttons on sidebar project chips (#4676)
2026-06-23 17:33:59 +00:00
" structured_code_default_view " : " auto " , # JSON/YAML fenced-block default render: auto | on | off (#484 follow-up). auto => Tree when line count >= structured_code_auto_tree_lines, else Raw.
" structured_code_auto_tree_lines " : 10 , # in 'auto' mode, minimum line count to default a JSON/YAML block to Tree view (preserves the original hardcoded >=10 behavior)
2026-05-08 18:45:53 +00:00
" session_endless_scroll " : False , # auto-load older transcript pages while scrolling upward
2026-06-12 01:45:41 +00:00
" chat_activity_display_mode " : " compact_worklog " , # compact_worklog | transparent_stream
2026-06-13 03:54:11 +00:00
" auto_scroll_follow " : True , # follow new output to the bottom while streaming (Codex/Claude-Code-style sticky bottom); the user scrolling up unpins and is respected
2026-06-10 00:02:35 +00:00
" worklog_details_expanded_default " : False , # opt-in: expand Worklog details by default; default remains folded
2026-06-21 19:42:42 +00:00
" hide_composer_attach " : False , # hide attach button in composer footer
" hide_composer_saved_prompts " : False , # hide saved prompts button in composer footer
" hide_composer_mic " : False , # hide dictation mic button in composer footer
2026-06-22 01:56:31 +00:00
" show_titlebar_profile " : False , # show profile switcher in app titlebar (opt-in)
2026-06-21 19:42:42 +00:00
" hide_composer_voice_mode " : False , # hide hands-free voice-mode button in composer footer
" hide_composer_yolo " : False , # hide YOLO chip in composer footer
" hide_composer_profile " : False , # hide profile chip in composer footer
" hide_composer_workspace " : False , # hide workspace controls in composer footer/mobile config panel
" hide_composer_mobile_config " : False , # hide mobile composer config button
" hide_composer_model " : False , # hide model chip in composer footer/mobile config panel
" hide_composer_quota_chip " : False , # hide provider quota chip in composer footer
" hide_composer_reasoning " : False , # hide reasoning chip in composer footer/mobile config panel
" hide_composer_toolsets " : False , # hide toolsets chip in composer footer
" hide_composer_status " : False , # hide status text in composer footer
" hide_composer_context " : False , # hide context indicator in composer footer/mobile config panel
" hide_composer_bg_badge " : False , # hide background-jobs badge in composer footer
2026-05-21 17:43:56 +00:00
" pinned_sessions_limit " : 3 , # maximum active pinned sessions shown in the sidebar
2026-05-22 18:20:44 +00:00
" inflight_state_max_sessions " : 8 , # max active-stream recovery snapshots kept in browser localStorage
" inflight_state_max_messages " : 24 , # max recent messages kept per recovery snapshot
" inflight_state_max_tool_calls " : 48 , # max recent tool-call records kept per recovery snapshot
" inflight_state_max_string_chars " : 60000 , # max string length kept inside a recovery snapshot field
" inflight_state_max_json_chars " : 1500000 , # max serialized recovery snapshot payload before pruning
2026-05-20 13:41:42 +00:00
" hidden_tabs " : [ ] , # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
2026-06-06 01:39:42 +00:00
" tab_order " : [ ] , # user-defined sidebar/rail tab order for reorderable tabs; chat/settings stay fixed
2026-07-01 02:11:54 +00:00
" composer_control_order " : [ ] , # user-defined composer footer control order; invalid/duplicate keys are ignored
2026-04-12 07:11:41 +00:00
" language " : " en " , # UI locale code; must match a key in static/i18n.js LOCALES
" bot_name " : os . getenv (
" HERMES_WEBUI_BOT_NAME " , " Hermes "
) , # display name for the assistant
" sound_enabled " : False , # play notification sound when assistant finishes
2026-05-16 22:04:56 +00:00
" rtl " : False , # right-to-left chat layout (chat messages + composer only)
2026-04-12 07:11:41 +00:00
" notifications_enabled " : False , # browser notification when tab is in background
2026-04-21 22:26:52 +00:00
" show_thinking " : True , # show/hide thinking/reasoning blocks in chat view
2026-06-10 00:02:35 +00:00
" simplified_tool_calling " : True , # legacy compatibility; Worklog renderer remains enabled
2026-06-04 03:15:53 +00:00
" terminal_auto_expand_on_output " : False , # auto-expand terminal panel when output arrives while collapsed
2026-06-15 00:31:13 +00:00
" workspace_todos_tab " : False , # show a Todos tab in the workspace panel (right side)
feat: P3 improvements — insights panel, rollback UI, voice mode, subagent tree, api redact toggle
- #464 Insights panel: usage analytics dashboard with session/message/token stats,
model breakdown, activity by day/hour charts, token breakdown (GET /api/insights)
- #466 Rollback UI: checkpoint list, diff viewer, restore confirmation
(api/rollback.py, GET /api/rollback/{list,diff}, POST /api/rollback/restore)
- #1333 Voice mode: turn-based STT→send→TTS loop using Web Speech API,
progressive enhancement with pulsing indicator and auto-resume
- #494 Subagent session tree: parent→children grouping in sidebar with
expand/collapse chevrons, child count badges, localStorage persistence
- #1396 API redact toggle: Settings checkbox to disable forced redaction for
self-hosted users (lazy check at call-time, default ON)
- #1385 Closed: compact tool activity toggle already exists in Settings
- #497 Commented: proposed shared-file bridge for cross-process gateway approvals
- i18n: tab_insights added to all 8 locales, voice/checkpoint keys to EN+RU
2026-05-01 13:43:10 +00:00
" api_redact_enabled " : True , # redact sensitive data (API keys, secrets) from API responses
2026-06-02 02:48:11 +00:00
" dashboard_plugins " : { } , # plugin_name -> bool, opt-in per plugin (default off per PF-10b)
2026-04-20 19:43:40 +00:00
" sidebar_density " : " compact " , # compact | detailed
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
" auto_title_refresh_every " : " 0 " , # adaptive title refresh: 0=off, 5/10/20=every N exchanges
2026-07-02 05:01:27 +00:00
" default_message_mode " : " steer " , # behavior when sending while agent is running: queue | interrupt | steer
2026-04-12 07:11:41 +00:00
" password_hash " : None , # PBKDF2-HMAC-SHA256 hash; None = auth disabled
2026-06-21 02:26:20 +00:00
" auth_disabled_acknowledged " : False , # user acknowledged unauthenticated risk
2026-06-28 14:10:35 +00:00
" provider_cost_budget " : None ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
}
2026-07-02 23:33:13 +00:00
_SETTINGS_SPEECH_KEYS = {
" tts_enabled " ,
" tts_auto_read " ,
" tts_engine " ,
" tts_voice " ,
" tts_rate " ,
" tts_pitch " ,
" voice_mode_button " ,
" voice_continuous " ,
" voice_silence_ms " ,
" raw_audio_mode " ,
}
_SETTINGS_PERSISTED_SPEECH_KEYS_FIELD = " persisted_speech_keys "
2026-06-10 00:02:35 +00:00
_SETTINGS_LEGACY_DROP_KEYS = {
" assistant_language " ,
" bubble_layout " ,
" default_model " ,
" activity_feed_expanded_default " ,
" simplified_tool_calling " ,
}
2026-07-01 02:11:54 +00:00
_COMPOSER_CONTROL_ORDER_KEYS = {
key for key in _SETTINGS_DEFAULTS if key . startswith ( " hide_composer_ " )
}
2026-04-30 02:59:26 +00:00
_SETTINGS_THEME_VALUES = { " light " , " dark " , " system " }
2026-04-18 06:37:09 +00:00
_SETTINGS_SKIN_VALUES = {
" default " ,
" ares " ,
" mono " ,
Release v0.51.242 — Release HJ (stage-q14) (#3500)
## 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>
2026-06-03 23:46:53 +00:00
" graphite " ,
2026-04-18 06:37:09 +00:00
" slate " ,
" poseidon " ,
" sisyphus " ,
" charizard " ,
2026-05-15 05:28:34 +00:00
" sienna " ,
2026-05-17 03:35:19 +00:00
" catppuccin " ,
2026-05-15 05:28:34 +00:00
" nous " ,
2026-05-18 11:10:17 +00:00
" geist-contrast " ,
2026-06-04 03:54:02 +00:00
" zeus " ,
2026-06-05 22:18:47 +00:00
" verdigris " ,
2026-06-30 22:13:14 +00:00
" neon-soft " ,
" neon-paint " ,
2026-04-18 06:37:09 +00:00
}
_SETTINGS_LEGACY_THEME_MAP = {
# Legacy full themes now map onto the closest supported theme + accent skin pair.
" slate " : ( " dark " , " slate " ) ,
" solarized " : ( " dark " , " poseidon " ) ,
" monokai " : ( " dark " , " sisyphus " ) ,
" nord " : ( " dark " , " slate " ) ,
" oled " : ( " dark " , " default " ) ,
}
def _normalize_appearance ( theme , skin ) - > tuple [ str , str ] :
""" Normalize a (theme, skin) pair, migrating legacy theme names.
Legacy migration table ( from ` _SETTINGS_LEGACY_THEME_MAP ` ) :
slate → ( " dark " , " slate " )
solarized → ( " dark " , " poseidon " )
monokai → ( " dark " , " sisyphus " )
nord → ( " dark " , " slate " )
oled → ( " dark " , " default " )
Unknown / custom theme names fall back to ( " dark " , " default " ) . This is a
behavior change vs . the pre - PR - #627 state, where the `theme` field was
open - ended ( " no enum gate -- allows custom themes " ) . Users who set a
custom CSS theme via ` data - theme ` will need to re - apply via skin or
custom CSS — see CHANGELOG entry for details .
The same mapping is mirrored in ` static / boot . js ` ( ` _LEGACY_THEME_MAP ` )
so client and server normalize identically ; keep them in sync .
"""
raw_theme = theme . strip ( ) . lower ( ) if isinstance ( theme , str ) else " "
raw_skin = skin . strip ( ) . lower ( ) if isinstance ( skin , str ) else " "
legacy = _SETTINGS_LEGACY_THEME_MAP . get ( raw_theme )
if legacy :
next_theme , legacy_skin = legacy
elif raw_theme in _SETTINGS_THEME_VALUES :
next_theme , legacy_skin = raw_theme , " default "
else :
# Unknown themes used to exist; default to dark so upgrades stay visually stable.
next_theme , legacy_skin = " dark " , " default "
next_skin = (
raw_skin
if raw_skin in _SETTINGS_SKIN_VALUES
else legacy_skin
)
return next_theme , next_skin
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
2026-04-12 07:11:41 +00:00
2026-07-02 23:33:13 +00:00
def _read_raw_settings_file ( ) - > dict :
""" Read settings.json without applying defaults. """
2026-04-17 03:16:07 +00:00
try :
2026-07-02 23:33:13 +00:00
if not SETTINGS_FILE . exists ( ) :
return { }
2026-04-17 03:16:07 +00:00
except OSError :
# PermissionError or other OS-level error (e.g. UID mismatch in Docker)
2026-07-02 23:33:13 +00:00
# Treat as missing rather than failing startup.
2026-04-17 03:16:07 +00:00
logger . debug ( " Cannot stat settings file %s (inaccessible?) " , SETTINGS_FILE )
2026-07-02 23:33:13 +00:00
return { }
2026-06-17 03:41:47 +00:00
2026-07-02 23:33:13 +00:00
try :
loaded = json . loads ( SETTINGS_FILE . read_text ( encoding = " utf-8 " ) )
except Exception :
logger . debug ( " Failed to load settings from %s " , SETTINGS_FILE )
return { }
return loaded if isinstance ( loaded , dict ) else { }
def _extract_persisted_speech_keys ( stored : dict ) - > set [ str ] :
if not isinstance ( stored , dict ) :
return set ( )
return { key for key in _SETTINGS_SPEECH_KEYS if key in stored }
def persisted_speech_settings_keys ( ) - > list [ str ] :
return sorted ( _extract_persisted_speech_keys ( _read_raw_settings_file ( ) ) )
2026-07-03 01:28:46 +00:00
def _settings_payload_for_write ( settings : dict , persisted_speech_keys : set [ str ] ) - > dict :
persisted = {
k : v
for k , v in settings . items ( )
if k not in { " default_model " , _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD }
}
for speech_key in _SETTINGS_SPEECH_KEYS :
if speech_key not in persisted_speech_keys :
persisted . pop ( speech_key , None )
return persisted
2026-07-02 23:33:13 +00:00
def load_settings ( ) - > dict :
""" Load settings from disk, merging with defaults for any missing keys. """
settings = dict ( _SETTINGS_DEFAULTS )
stored = _read_raw_settings_file ( )
if isinstance ( stored , dict ) :
if (
" worklog_details_expanded_default " not in stored
and " activity_feed_expanded_default " in stored
) :
settings [ " worklog_details_expanded_default " ] = bool (
stored . get ( " activity_feed_expanded_default " )
)
settings . update (
{
k : v
for k , v in stored . items ( )
if k not in _SETTINGS_LEGACY_DROP_KEYS
and k != _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD
}
)
if (
" default_message_mode " not in stored
and " busy_input_mode " in stored
) :
settings [ " default_message_mode " ] = stored . get ( " busy_input_mode " )
settings . pop ( " busy_input_mode " , None )
# Grandfather established installs OFF for show_cli_sessions (#3988).
# The default flipped True so NEW users see CLI/TUI/messaging
# sessions without hunting for the toggle — but an existing user
# who never opted in should not have their sidebar silently change.
# Treat the install as established (and pin the old False default)
# when show_cli_sessions is absent AND the file already carries
# real user state — either onboarding was completed, or some
# setting OTHER than a not-yet-completed onboarding flag has been
# persisted. Keying on "has saved user state" (not just
# onboarding_completed) also covers a CLI-configured user who
# tweaked a WebUI setting before running the wizard. A genuinely
# new / still-mid-onboarding file falls through to the True default.
_established_keys = [
k for k in stored
if k not in ( " show_cli_sessions " , " onboarding_completed " )
]
if " show_cli_sessions " not in stored and (
bool ( stored . get ( " onboarding_completed " ) ) or _established_keys
) :
settings [ " show_cli_sessions " ] = False
# Force-off-for-everyone migration for virtualize_transcript (#4343).
# The feature shipped opt-OUT/default-on in #4325, then proved to
# cause scroll-up flicker on long sessions (variable-height anchor
# oscillation). It is now EXPERIMENTAL/opt-IN (default off). Any
# stored virtualize_transcript=True from the #4325 window is a stale
# pre-flip value and must be reset to off, so 100% of existing users
# land on off — re-enabling requires an explicit opt-in made AFTER
# the flip, which writes virtualize_transcript_optin=True alongside.
# Honor a stored True only when that marker is present.
if not bool ( stored . get ( " virtualize_transcript_optin " ) ) :
settings [ " virtualize_transcript " ] = False
2026-04-18 06:37:09 +00:00
settings [ " theme " ] , settings [ " skin " ] = _normalize_appearance (
stored . get ( " theme " ) if isinstance ( stored , dict ) else settings . get ( " theme " ) ,
stored . get ( " skin " ) if isinstance ( stored , dict ) else settings . get ( " skin " ) ,
)
2026-04-20 22:12:01 +00:00
settings [ " default_model " ] = get_effective_default_model ( )
2026-05-20 12:55:17 +00:00
try :
model_cfg = get_config ( ) . get ( " model " , { } )
if isinstance ( model_cfg , dict ) and model_cfg . get ( " provider " ) :
settings [ " default_model_provider " ] = str ( model_cfg . get ( " provider " ) )
except Exception :
logger . debug ( " Failed to resolve default model provider for settings " )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
return settings
2026-04-12 07:11:41 +00:00
2026-04-20 22:12:01 +00:00
_SETTINGS_ALLOWED_KEYS = set ( _SETTINGS_DEFAULTS . keys ( ) ) - {
" password_hash " ,
" default_model " ,
2026-06-10 00:02:35 +00:00
" simplified_tool_calling " ,
2026-04-20 22:12:01 +00:00
}
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
_SETTINGS_ENUM_VALUES = {
2026-06-26 15:42:50 +00:00
" send_key " : { " enter " , " ctrl+enter " , " shift+enter " } ,
2026-04-20 19:43:40 +00:00
" sidebar_density " : { " compact " , " detailed " } ,
2026-05-14 02:59:52 +00:00
" font_size " : { " small " , " default " , " large " , " xlarge " } ,
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
" auto_title_refresh_every " : { " 0 " , " 5 " , " 10 " , " 20 " } ,
2026-07-02 05:01:27 +00:00
" default_message_mode " : { " queue " , " interrupt " , " steer " } ,
2026-06-12 01:45:41 +00:00
" chat_activity_display_mode " : { " compact_worklog " , " transparent_stream " } ,
2026-06-23 17:33:59 +00:00
" structured_code_default_view " : { " auto " , " on " , " off " } ,
2026-04-12 07:11:41 +00:00
}
2026-05-21 17:43:56 +00:00
_SETTINGS_INT_RANGES = {
" pinned_sessions_limit " : ( 1 , 99 ) ,
2026-05-22 18:20:44 +00:00
" inflight_state_max_sessions " : ( 1 , 25 ) ,
" inflight_state_max_messages " : ( 1 , 100 ) ,
" inflight_state_max_tool_calls " : ( 1 , 200 ) ,
" inflight_state_max_string_chars " : ( 1000 , 500000 ) ,
" inflight_state_max_json_chars " : ( 100000 , 4000000 ) ,
2026-06-23 17:33:59 +00:00
" structured_code_auto_tree_lines " : ( 1 , 1000 ) ,
2026-07-02 19:43:24 +00:00
" voice_silence_ms " : ( 200 , 60000 ) ,
}
_SETTINGS_FLOAT_RANGES = {
" tts_rate " : ( 0.5 , 2.0 ) ,
" tts_pitch " : ( 0.0 , 2.0 ) ,
2026-05-21 17:43:56 +00:00
}
2026-04-12 07:11:41 +00:00
_SETTINGS_BOOL_KEYS = {
" onboarding_completed " ,
" show_token_usage " ,
2026-05-16 23:05:09 +00:00
" show_quota_chip " ,
2026-06-10 23:46:39 +00:00
" show_conversation_outline " ,
2026-06-28 22:47:42 +00:00
" show_busy_placeholder_hint " ,
2026-05-21 17:43:48 +00:00
" hide_empty_state_suggestions " ,
2026-06-17 01:52:06 +00:00
" virtualize_transcript " ,
2026-06-17 03:41:47 +00:00
" virtualize_transcript_optin " ,
2026-05-04 19:08:07 +00:00
" show_tps " ,
2026-05-11 19:13:26 +00:00
" fade_text_effect " ,
2026-04-12 07:11:41 +00:00
" show_cli_sessions " ,
2026-07-02 01:40:19 +00:00
" show_claude_code_sessions " ,
2026-06-06 00:48:27 +00:00
" show_cron_sessions " ,
2026-06-29 22:40:52 +00:00
" show_webhook_sessions " ,
2026-05-15 06:17:40 +00:00
" show_previous_messaging_sessions " ,
2026-04-12 07:11:41 +00:00
" sync_to_insights " ,
" check_for_updates " ,
2026-05-24 14:20:31 +00:00
" ignore_agent_updates " ,
2026-05-13 21:22:26 +00:00
" whats_new_summary_enabled " ,
2026-07-02 19:43:24 +00:00
" tts_enabled " ,
" tts_auto_read " ,
" voice_mode_button " ,
" voice_continuous " ,
" raw_audio_mode " ,
2026-04-12 07:11:41 +00:00
" sound_enabled " ,
2026-05-16 22:04:56 +00:00
" rtl " ,
2026-04-12 07:11:41 +00:00
" notifications_enabled " ,
2026-04-21 22:26:52 +00:00
" show_thinking " ,
2026-06-04 03:15:53 +00:00
" terminal_auto_expand_on_output " ,
2026-06-15 00:31:13 +00:00
" workspace_todos_tab " ,
feat: P3 improvements — insights panel, rollback UI, voice mode, subagent tree, api redact toggle
- #464 Insights panel: usage analytics dashboard with session/message/token stats,
model breakdown, activity by day/hour charts, token breakdown (GET /api/insights)
- #466 Rollback UI: checkpoint list, diff viewer, restore confirmation
(api/rollback.py, GET /api/rollback/{list,diff}, POST /api/rollback/restore)
- #1333 Voice mode: turn-based STT→send→TTS loop using Web Speech API,
progressive enhancement with pulsing indicator and auto-resume
- #494 Subagent session tree: parent→children grouping in sidebar with
expand/collapse chevrons, child count badges, localStorage persistence
- #1396 API redact toggle: Settings checkbox to disable forced redaction for
self-hosted users (lazy check at call-time, default ON)
- #1385 Closed: compact tool activity toggle already exists in Settings
- #497 Commented: proposed shared-file bridge for cross-process gateway approvals
- i18n: tab_insights added to all 8 locales, voice/checkpoint keys to EN+RU
2026-05-01 13:43:10 +00:00
" api_redact_enabled " ,
2026-05-08 18:31:43 +00:00
" session_jump_buttons " ,
2026-06-21 00:03:02 +00:00
" render_user_markdown " ,
2026-06-30 03:52:57 +00:00
" large_text_paste_as_attachment " ,
2026-06-30 19:49:07 +00:00
" project_quick_create_buttons " ,
2026-05-08 18:45:53 +00:00
" session_endless_scroll " ,
fix(scroll): guard _scrollAfterMessageRender and _finishDone against pin hysteresis snap-back
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.
2026-06-11 19:48:14 +00:00
" auto_scroll_follow " ,
2026-06-10 00:02:35 +00:00
" worklog_details_expanded_default " ,
2026-06-21 02:26:20 +00:00
" auth_disabled_acknowledged " ,
2026-06-21 19:42:42 +00:00
" hide_composer_attach " ,
" hide_composer_saved_prompts " ,
" hide_composer_mic " ,
2026-06-22 01:56:31 +00:00
" show_titlebar_profile " ,
2026-06-21 19:42:42 +00:00
" hide_composer_voice_mode " ,
" hide_composer_yolo " ,
" hide_composer_profile " ,
" hide_composer_workspace " ,
" hide_composer_mobile_config " ,
" hide_composer_model " ,
" hide_composer_quota_chip " ,
" hide_composer_reasoning " ,
" hide_composer_toolsets " ,
" hide_composer_status " ,
" hide_composer_context " ,
" hide_composer_bg_badge " ,
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
}
2026-04-08 14:55:03 +00:00
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
2026-04-12 07:11:41 +00:00
_SETTINGS_LANG_RE = __import__ ( " re " ) . compile ( r " ^[a-zA-Z] { 2,10}(-[a-zA-Z0-9] { 2,8})?$ " )
2026-07-02 19:43:24 +00:00
_SETTINGS_TTS_ENGINE_RE = __import__ ( " re " ) . compile ( r " ^[a-zA-Z0-9][a-zA-Z0-9_-] { 0,63}$ " )
2026-04-12 07:11:41 +00:00
2026-06-21 02:04:09 +00:00
_SETTINGS_WRITE_VERSION = 0
_SETTINGS_WRITE_LOCK = __import__ ( " threading " ) . Lock ( )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
2026-06-29 05:59:51 +00:00
def _coerce_provider_cost_budget ( value : Any ) - > float | None :
""" Normalize a monthly budget to the persisted two-decimal representation. """
try :
rounded = round ( float ( value ) , 2 )
except ( TypeError , ValueError ) :
return None
if not ( 0 < rounded < 1e9 ) or not math . isfinite ( rounded ) :
return None
return rounded
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
def save_settings ( settings : dict ) - > dict :
""" Save settings to disk. Returns the merged settings. Ignores unknown keys. """
2026-07-02 23:33:13 +00:00
raw_settings = _read_raw_settings_file ( )
persisted_speech_keys = _extract_persisted_speech_keys ( raw_settings )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
current = load_settings ( )
2026-07-02 23:33:13 +00:00
applied_speech_keys : set [ str ] = set ( )
2026-06-10 00:02:35 +00:00
if (
" worklog_details_expanded_default " not in settings
and " activity_feed_expanded_default " in settings
) :
settings [ " worklog_details_expanded_default " ] = settings . get (
" activity_feed_expanded_default "
)
settings . pop ( " activity_feed_expanded_default " , None )
2026-07-02 05:01:27 +00:00
if (
" default_message_mode " not in settings
and " busy_input_mode " in settings
) :
settings [ " default_message_mode " ] = settings . get ( " busy_input_mode " )
settings . pop ( " busy_input_mode " , None )
2026-06-10 00:02:35 +00:00
settings . pop ( " simplified_tool_calling " , None )
2026-04-18 06:37:09 +00:00
pending_theme = current . get ( " theme " )
pending_skin = current . get ( " skin " )
theme_was_explicit = False
skin_was_explicit = False
feat: Sprint 19 — password auth, security headers, login page
Auth system (off by default, zero friction for localhost):
- New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt),
signed HMAC session cookies (24h TTL), auth middleware
- Enable via HERMES_WEBUI_PASSWORD env var or Settings panel
- Minimal dark-themed login page at /login (self-contained HTML)
- POST /api/auth/login, /api/auth/logout, GET /api/auth/status
- Settings panel: "Access Password" field + "Sign Out" button
- password_hash added to settings.json (null = auth disabled)
Security hardening:
- Security headers on all responses: X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: same-origin
- POST body size limit: 20MB cap in read_body() to prevent DoS
Closes #23. 9 new tests. Total: 304 passed, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:53:26 +00:00
# Handle _set_password: hash and store as password_hash
2026-05-13 17:08:37 +00:00
_password_changed = False
2026-04-12 07:11:41 +00:00
raw_pw = settings . pop ( " _set_password " , None )
feat: Sprint 19 — password auth, security headers, login page
Auth system (off by default, zero friction for localhost):
- New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt),
signed HMAC session cookies (24h TTL), auth middleware
- Enable via HERMES_WEBUI_PASSWORD env var or Settings panel
- Minimal dark-themed login page at /login (self-contained HTML)
- POST /api/auth/login, /api/auth/logout, GET /api/auth/status
- Settings panel: "Access Password" field + "Sign Out" button
- password_hash added to settings.json (null = auth disabled)
Security hardening:
- Security headers on all responses: X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: same-origin
- POST body size limit: 20MB cap in read_body() to prevent DoS
Closes #23. 9 new tests. Total: 304 passed, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:53:26 +00:00
if raw_pw and isinstance ( raw_pw , str ) and raw_pw . strip ( ) :
release: v0.39.0 — security hardening, 12 fixes (#171)
* Security: harden auth, CSRF, SSRF, XSS, and env race conditions
Twelve fixes from a full security audit:
CRITICAL
- Add CSRF Origin/Referer validation on all POST endpoints
(prevents cross-origin abuse of self-update, settings, file ops)
HIGH
- Unify password hashing: config.py now uses PBKDF2 (600k iters)
instead of single-iteration SHA-256
- Add per-IP rate limiting on login (5 attempts/60s, 429 on excess)
MEDIUM
- Validate session IDs as hex-only before filesystem operations
(prevents path traversal via crafted session ID)
- SSRF: resolve DNS before private-IP check in model fetching
(prevents DNS rebinding to internal services)
- Warn loudly when binding non-loopback without password set
- SSE env var mutations: wrap sync chat + streaming restore in _ENV_LOCK
- Force Content-Disposition:attachment for HTML/XHTML/SVG uploads
(prevents stored XSS via uploaded files)
LOW
- Extend HMAC session signature from 64 to 128 bits
- Add resolve()+relative_to() check on skills path construction
- Set Secure flag on session cookie when connection is HTTPS
- Sanitize exception messages to strip filesystem paths
No breaking changes. All fixes are backward-compatible.
* fix: use getattr for Secure cookie SSL detection
handler.request.getpeercert raises AttributeError on plain sockets
(non-SSL). Use getattr(..., None) to safely check for SSL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* tests: add sprint 29 security hardening coverage (PR #171)
33 tests covering all 12 security fixes:
- CSRF origin/referer validation
- Login rate limiting (5 attempts/60s)
- Session ID hex validation (path traversal prevention)
- Error path sanitization (_sanitize_error)
- Secure cookie getattr safety
- HMAC signature length (64->128 bit)
- Skills path traversal prevention
- Content-Disposition for HTML/SVG/XHTML
- PBKDF2 password hashing verification
- Non-loopback startup warning
- SSRF DNS guard code presence
- _ENV_LOCK export from streaming module
* release: v0.39.0 — security hardening, 12 fixes (#171)
---------
Co-authored-by: betamod <matthew.sloly@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 05:26:03 +00:00
# Use PBKDF2 from auth module (600k iterations) -- never raw SHA-256
from api . auth import _hash_password
2026-04-12 07:11:41 +00:00
current [ " password_hash " ] = _hash_password ( raw_pw . strip ( ) )
2026-05-13 17:08:37 +00:00
_password_changed = True
2026-04-03 13:17:32 +00:00
# Handle _clear_password: explicitly disable auth
2026-04-12 07:11:41 +00:00
if settings . pop ( " _clear_password " , False ) :
current [ " password_hash " ] = None
2026-05-13 17:08:37 +00:00
_password_changed = True
2026-06-02 02:48:11 +00:00
# Deep-merge dashboard_plugins dict (plugin_name -> bool)
_dashboard_plugins = settings . get ( " dashboard_plugins " )
if isinstance ( _dashboard_plugins , dict ) :
current_dash = current . get ( " dashboard_plugins " , { } )
if isinstance ( current_dash , dict ) :
2026-06-02 03:39:41 +00:00
# Coerce values to bool + keep only str keys so settings.json can't be
# polluted with non-bool/non-str junk from a crafted POST.
current_dash . update ( { k : bool ( v ) for k , v in _dashboard_plugins . items ( ) if isinstance ( k , str ) } )
2026-06-02 02:48:11 +00:00
current [ " dashboard_plugins " ] = current_dash
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
for k , v in settings . items ( ) :
2026-07-02 23:33:13 +00:00
key_is_speech = k in _SETTINGS_SPEECH_KEYS
2026-06-02 03:22:25 +00:00
# dashboard_plugins is deep-merged above (not a flat allowlisted scalar).
2026-06-02 02:48:11 +00:00
if k == " dashboard_plugins " :
continue
2026-06-02 03:22:25 +00:00
if k in _SETTINGS_ALLOWED_KEYS :
2026-04-18 06:37:09 +00:00
if k == " theme " :
if isinstance ( v , str ) and v . strip ( ) :
pending_theme = v
theme_was_explicit = True
continue
if k == " skin " :
if isinstance ( v , str ) and v . strip ( ) :
pending_skin = v
skin_was_explicit = True
continue
feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:31:31 +00:00
# Validate enum-constrained keys
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES [ k ] :
continue
2026-05-21 17:43:56 +00:00
# Validate bounded integer settings.
if k in _SETTINGS_INT_RANGES :
try :
v = int ( v )
except ( TypeError , ValueError ) :
continue
min_value , max_value = _SETTINGS_INT_RANGES [ k ]
if v < min_value or v > max_value :
continue
2026-07-02 19:43:24 +00:00
if k in _SETTINGS_FLOAT_RANGES :
try :
v = float ( v )
except ( TypeError , ValueError ) :
continue
min_value , max_value = _SETTINGS_FLOAT_RANGES [ k ]
if not math . isfinite ( v ) or v < min_value or v > max_value :
continue
if k == " tts_engine " :
if not isinstance ( v , str ) :
continue
v = v . strip ( )
if not _SETTINGS_TTS_ENGINE_RE . match ( v ) :
continue
if k == " tts_voice " :
if not isinstance ( v , str ) or len ( v ) > 200 or " \x00 " in v :
continue
2026-04-08 14:55:03 +00:00
# Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN')
2026-04-12 07:11:41 +00:00
if k == " language " and (
not isinstance ( v , str ) or not _SETTINGS_LANG_RE . match ( v )
) :
2026-04-08 14:55:03 +00:00
continue
2026-07-01 02:11:54 +00:00
# Validate list-valued ordering settings. Chat/settings stay fixed
# for tabs; composer ordering only accepts known control keys.
# Duplicates are collapsed while preserving the first requested order.
if k in { " hidden_tabs " , " tab_order " , " composer_control_order " } :
2026-05-20 13:41:42 +00:00
if not isinstance ( v , list ) :
continue
2026-06-06 01:39:42 +00:00
seen = set ( )
cleaned = [ ]
for s in v :
if not isinstance ( s , str ) :
continue
s = s . strip ( )
2026-07-01 02:11:54 +00:00
if not s or s in seen :
continue
if k in { " hidden_tabs " , " tab_order " } and s in { " chat " , " settings " } :
continue
if k == " composer_control_order " and s not in _COMPOSER_CONTROL_ORDER_KEYS :
2026-06-06 01:39:42 +00:00
continue
seen . add ( s )
cleaned . append ( s )
v = cleaned
2026-06-28 14:10:35 +00:00
if k == " provider_cost_budget " :
if v is None or v == " " :
current [ k ] = None
continue
2026-06-29 05:59:51 +00:00
budget = _coerce_provider_cost_budget ( v )
if budget is None :
2026-06-28 14:10:35 +00:00
continue
2026-06-29 05:59:51 +00:00
current [ k ] = budget
2026-06-28 14:10:35 +00:00
continue
2026-04-04 02:04:41 +00:00
# Coerce bool keys
if k in _SETTINGS_BOOL_KEYS :
v = bool ( v )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
current [ k ] = v
2026-07-02 23:33:13 +00:00
if key_is_speech :
applied_speech_keys . add ( k )
2026-04-18 06:37:09 +00:00
theme_value = pending_theme
skin_value = pending_skin
if theme_was_explicit and not skin_was_explicit :
raw_theme = pending_theme . strip ( ) . lower ( ) if isinstance ( pending_theme , str ) else " "
if raw_theme not in _SETTINGS_THEME_VALUES :
skin_value = None
current [ " theme " ] , current [ " skin " ] = _normalize_appearance ( theme_value , skin_value )
2026-04-13 21:28:24 +00:00
current [ " default_workspace " ] = str (
resolve_default_workspace ( current . get ( " default_workspace " ) )
)
2026-07-02 23:33:13 +00:00
effective_persisted_speech_keys = set ( persisted_speech_keys )
effective_persisted_speech_keys . update ( applied_speech_keys )
2026-07-03 01:28:46 +00:00
persisted = _settings_payload_for_write ( current , effective_persisted_speech_keys )
v0.50.211: compact timestamps, adaptive title refresh, settings picker fix (#1061)
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes #1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:50:58 +00:00
SETTINGS_FILE . parent . mkdir ( parents = True , exist_ok = True )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
SETTINGS_FILE . write_text (
2026-04-20 22:12:01 +00:00
json . dumps ( persisted , ensure_ascii = False , indent = 2 ) ,
2026-04-12 07:11:41 +00:00
encoding = " utf-8 " ,
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
)
2026-06-21 02:04:09 +00:00
global _SETTINGS_WRITE_VERSION
with _SETTINGS_WRITE_LOCK :
_SETTINGS_WRITE_VERSION + = 1
2026-05-13 17:08:37 +00:00
# Invalidate the in-memory password hash cache so the next call to
# get_password_hash() picks up the new value from disk immediately.
if _password_changed :
from api . auth import _invalidate_password_hash_cache
_invalidate_password_hash_cache ( )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
# Update runtime defaults so new sessions use them immediately
2026-04-20 22:12:01 +00:00
global DEFAULT_WORKSPACE
2026-04-12 07:11:41 +00:00
if " default_workspace " in current :
2026-04-13 21:28:24 +00:00
DEFAULT_WORKSPACE = resolve_default_workspace ( current [ " default_workspace " ] )
2026-04-20 22:12:01 +00:00
current [ " default_model " ] = get_effective_default_model ( )
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
return current
2026-04-12 07:11:41 +00:00
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
# Apply saved settings on startup (override env-derived defaults)
2026-04-17 01:09:16 +00:00
# Exception: if HERMES_WEBUI_DEFAULT_WORKSPACE is explicitly set in the
# environment, it wins over whatever settings.json has stored. Persisted
# config must never shadow an explicit env-var override (Docker deployments
# rely on this — otherwise deleting settings.json is the only escape).
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
_startup_settings = load_settings ( )
2026-04-17 03:16:07 +00:00
try :
_settings_file_exists = SETTINGS_FILE . exists ( )
except OSError :
_settings_file_exists = False
if _settings_file_exists :
2026-04-17 01:09:16 +00:00
if not os . getenv ( " HERMES_WEBUI_DEFAULT_WORKSPACE " ) :
DEFAULT_WORKSPACE = resolve_default_workspace (
_startup_settings . get ( " default_workspace " )
)
2026-04-20 22:12:01 +00:00
_startup_settings . pop ( " default_model " , None ) # always drop stale value; model comes from config.yaml
2026-04-13 21:28:24 +00:00
if _startup_settings . get ( " default_workspace " ) != str ( DEFAULT_WORKSPACE ) :
_startup_settings [ " default_workspace " ] = str ( DEFAULT_WORKSPACE )
try :
2026-07-03 01:28:46 +00:00
startup_persisted_speech_keys = _extract_persisted_speech_keys (
_read_raw_settings_file ( )
)
2026-04-13 21:28:24 +00:00
SETTINGS_FILE . write_text (
2026-07-03 01:28:46 +00:00
json . dumps (
_settings_payload_for_write (
_startup_settings , startup_persisted_speech_keys
) ,
ensure_ascii = False ,
indent = 2 ,
) ,
2026-04-13 21:28:24 +00:00
encoding = " utf-8 " ,
)
except Exception :
pass
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
2026-03-31 03:40:19 +00:00
# ── SESSIONS in-memory cache (LRU OrderedDict) ───────────────────────────────
SESSIONS : collections . OrderedDict = collections . OrderedDict ( )
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
# ── Profile state initialisation ────────────────────────────────────────────
# Must run after all imports are resolved to correctly patch module-level caches
try :
from api . profiles import init_profile_state
2026-04-12 07:11:41 +00:00
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:50:21 +00:00
init_profile_state ( )
except ImportError :
pass # hermes_cli not available -- default profile only
2026-06-20 21:22:53 +00:00
# Run the provider-model seeder once at import time. Must be at the END of the
# module because _seed_provider_models_from_core() calls _get_label_for_model,
# which is defined ~3000 lines above. Placing the invocation earlier (e.g. right
# after the seeder's def) caused a NameError that the bare except silently
# swallowed — exactly when the seeder had real work to do (#4413).
try :
_seed_provider_models_from_core ( )
except ImportError :
pass # hermes_cli not available (standalone deployment)
except Exception :
logger . warning ( " provider-model seeder failed " , exc_info = True )