fix(settings): show live models in default model picker and apply to new chats (#872) (#900)

* fix(settings): show live models in default model picker and apply to new chats (#872)

Two related bugs:
1. Settings > Preferences > Default Model dropdown only showed static models
   from /api/models — live-fetched models (e.g. @nous:anthropic/claude-opus-4.7)
   were missing. Now calls _fetchLiveModels() on the settings picker too.
2. New chats ignored the saved default model preference — they always used the
   chat-header dropdown value (which reflects the previous session's model).
   Now newSession() uses the saved default_model and syncs the dropdown.

Extracted _addLiveModelsToSelect() from _fetchLiveModels() so cached live models
can be applied to any <select> element (chat-header or settings picker).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): update live-model prefix tests for _addLiveModelsToSelect extraction

The tests searched for og.dataset.provider, _isPortalFetch, and openrouter
exclusion patterns inside _fetchLiveModels(). These were extracted into
_addLiveModelsToSelect() as part of the #872 fix. Updated regex targets to
check _addLiveModelsToSelect first, falling back to _fetchLiveModels.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add multi-tab note on window._defaultModel

Clarifies that window._defaultModel is per-page-load and not synced
across browser tabs, following maintainer feedback on #889.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: CHANGELOG for v0.50.170

* chore: trigger PR refresh after rebase

---------

Co-authored-by: fr33m1nd <bergeouss@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-23 09:58:15 -07:00
committed by GitHub
parent cd01e4d5ba
commit 498156a3e8
6 changed files with 92 additions and 66 deletions

View File

@@ -29,6 +29,11 @@
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`, workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616) `api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
## [v0.50.170] — 2026-04-23
### Fixed
- **Settings default model picker shows live-fetched models** — the Settings → Preferences → Default Model dropdown previously only showed static models from `_PROVIDER_MODELS`. It now calls `_fetchLiveModels()` via the new `_addLiveModelsToSelect()` helper, consistent with the chat-header dropdown. New sessions also respect the saved default model (`window._defaultModel`) instead of always reading the chat-header value, which reflected the previous session's model. (`static/ui.js`, `static/sessions.js`, `static/panels.js`) Closes #872. Co-authored by @bergeouss.
## [v0.50.163] — 2026-04-23 ## [v0.50.163] — 2026-04-23
### Fixed ### Fixed

View File

@@ -1431,7 +1431,7 @@ async function loadSettingsPanel(){
setLocale(resolvedLanguage); setLocale(resolvedLanguage);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
} }
// Populate model dropdown from /api/models // Populate model dropdown from /api/models + live model fetch (#872)
const modelSel=$('settingsModel'); const modelSel=$('settingsModel');
if(modelSel){ if(modelSel){
modelSel.innerHTML=''; modelSel.innerHTML='';
@@ -1441,6 +1441,7 @@ async function loadSettingsPanel(){
for(const g of ((models||{}).groups||[])){ for(const g of ((models||{}).groups||[])){
const og=document.createElement('optgroup'); const og=document.createElement('optgroup');
og.label=g.provider; og.label=g.provider;
if(g.provider_id) og.dataset.provider=g.provider_id;
for(const m of g.models){ for(const m of g.models){
const opt=document.createElement('option'); const opt=document.createElement('option');
opt.value=m.id;opt.textContent=m.label; opt.value=m.id;opt.textContent=m.label;
@@ -1448,6 +1449,11 @@ async function loadSettingsPanel(){
} }
modelSel.appendChild(og); modelSel.appendChild(og);
} }
// Append live-fetched models for the active provider, same as the
// chat-header dropdown does via _fetchLiveModels() (#872).
if(models.active_provider && typeof _fetchLiveModels==='function'){
_fetchLiveModels(models.active_provider, modelSel);
}
}catch(e){} }catch(e){}
_settingsHermesDefaultModelOnOpen=(models&&models.default_model)||''; _settingsHermesDefaultModelOnOpen=(models&&models.default_model)||'';
modelSel.value=_settingsHermesDefaultModelOnOpen; modelSel.value=_settingsHermesDefaultModelOnOpen;

View File

@@ -62,12 +62,23 @@ async function newSession(flash){
const switchWs=S._profileSwitchWorkspace; const switchWs=S._profileSwitchWorkspace;
S._profileSwitchWorkspace=null; S._profileSwitchWorkspace=null;
const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null); const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null);
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs,profile:S.activeProfile||'default'})}); // Use the saved default model for new sessions (#872). The user's saved
// default_model (from Settings) takes priority over the chat-header dropdown
// value, which reflects the *previous* session's model. Fall back to the
// dropdown value only when no default_model is configured.
const newModel=window._defaultModel||$('modelSelect').value;
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:newModel,workspace:inheritWs,profile:S.activeProfile||'default'})});
S.session=data.session;S.messages=data.session.messages||[]; S.session=data.session;S.messages=data.session.messages||[];
S.lastUsage={...(data.session.last_usage||{})}; S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true; if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id); localStorage.setItem('hermes-webui-session',S.session.session_id);
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0); _setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
// Sync chat-header dropdown to the session's model so the UI reflects
// the default model the server actually used (#872).
if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){
_applyModelToDropdown(S.session.model,$('modelSelect'));
if(typeof syncModelChip==='function') syncModelChip();
}
// Reset per-session visual state: a fresh chat is idle even if another // Reset per-session visual state: a fresh chat is idle even if another
// conversation is still streaming in the background. // conversation is still streaming in the background.
S.busy=false; S.busy=false;

View File

@@ -84,6 +84,9 @@ async function populateModelDropdown(){
if(!data.groups||!data.groups.length) return; // keep HTML defaults if(!data.groups||!data.groups.length) return; // keep HTML defaults
// Store active provider globally so the send path can warn on mismatch // Store active provider globally so the send path can warn on mismatch
window._activeProvider=data.active_provider||null; window._activeProvider=data.active_provider||null;
// Store default model so newSession() can apply it (#872).
// Per-page-load — not synced across browser tabs.
window._defaultModel=data.default_model||null;
// Clear existing options // Clear existing options
sel.innerHTML=''; sel.innerHTML='';
_dynamicModelLabels={}; _dynamicModelLabels={};
@@ -118,72 +121,61 @@ async function populateModelDropdown(){
// Cache so we don't re-fetch on every page load // Cache so we don't re-fetch on every page load
const _liveModelCache={}; const _liveModelCache={};
function _addLiveModelsToSelect(provider, models, sel){
if(!provider||!models||!models.length||!sel) return 0;
const currentVal=sel.value;
let providerGroup=null;
for(const og of sel.querySelectorAll('optgroup')){
if(og.dataset.provider&&og.dataset.provider===provider){
providerGroup=og; break;
}
if(og.label&&og.label.toLowerCase().includes(provider.toLowerCase())){
providerGroup=og; break;
}
}
if(!providerGroup){
providerGroup=document.createElement('optgroup');
providerGroup.label=provider.charAt(0).toUpperCase()+provider.slice(1)+' (live)';
sel.appendChild(providerGroup);
}
const existingIds=new Set([...sel.options].map(o=>o.value));
let added=0;
const _ap=(window._activeProvider||'').toLowerCase();
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && provider===_ap;
for(const m of models){
let mid=m.id;
if(_isPortalFetch && !mid.startsWith('@')){
mid=`@${provider}:${mid}`;
}
if(existingIds.has(mid)) continue;
const opt=document.createElement('option');
opt.value=mid;
opt.textContent=m.label||m.id;
opt.title='Live model — fetched from provider';
providerGroup.appendChild(opt);
_dynamicModelLabels[mid]=m.label||m.id;
added++;
}
if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel);
return added;
}
async function _fetchLiveModels(provider, sel){ async function _fetchLiveModels(provider, sel){
if(!provider||!sel) return; if(!provider||!sel) return;
// Don't fetch for providers where we know it's unsupported or unnecessary // Already fetched — apply cached models to this select element (#872)
// All providers now supported via agent's provider_model_ids() — no exclusions needed if(_liveModelCache[provider]){
if(_liveModelCache[provider]) return; // already fetched this session const added=_addLiveModelsToSelect(provider,_liveModelCache[provider],sel);
if(added>0 && typeof syncModelChip==='function') syncModelChip();
return;
}
try{ try{
const url=new URL('api/models/live',location.href); const url=new URL('api/models/live',location.href);
url.searchParams.set('provider',provider); url.searchParams.set('provider',provider);
const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json()); const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json());
if(!data.models||!data.models.length) return; if(!data.models||!data.models.length) return;
_liveModelCache[provider]=data.models; _liveModelCache[provider]=data.models;
// Remember current selection before rebuilding options const added=_addLiveModelsToSelect(provider,data.models,sel);
const currentVal=sel.value;
// Rebuild the optgroup for this provider with live models
// Keep other providers' optgroups intact
let providerGroup=null;
for(const og of sel.querySelectorAll('optgroup')){
// Prefer exact data-provider match (set from provider_id in API response)
// over substring label match — avoids false positives like 'zai' not matching
// 'Z.AI / GLM' and vice versa.
if(og.dataset.provider&&og.dataset.provider===provider){
providerGroup=og; break;
}
if(og.label&&og.label.toLowerCase().includes(provider.toLowerCase())){
providerGroup=og; break;
}
}
if(!providerGroup){
// No existing group — add a new one
providerGroup=document.createElement('optgroup');
providerGroup.label=provider.charAt(0).toUpperCase()+provider.slice(1)+' (live)';
sel.appendChild(providerGroup);
}
// Rebuild options from live data
const existingIds=new Set([...sel.options].map(o=>o.value));
let added=0;
// Apply @provider: prefix to live-fetched model IDs (mirrors the server-side
// behaviour for static lists). Portal providers like Nous return upstream
// vendor IDs (e.g. "minimax/minimax-m2.7", "anthropic/claude-opus-4.7") —
// without a `@nous:` prefix, `resolve_model_provider()` sees the slash and
// mis-routes via OpenRouter → 404. Prefixing with `@${provider}:` makes
// the portal hint explicit so routing honours it (#854).
//
// Scope: only apply the prefix when this fetch is for the active provider
// and that provider is a portal (not OpenRouter / custom, which use bare
// or cross-namespace IDs natively). Skip IDs that already carry an
// `@prefix:` — they've already been disambiguated upstream.
const _ap=(window._activeProvider||'').toLowerCase();
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && provider===_ap;
for(const m of data.models){
let mid=m.id;
if(_isPortalFetch && !mid.startsWith('@')){
mid=`@${provider}:${mid}`;
}
if(existingIds.has(mid)) continue; // already shown from static list
const opt=document.createElement('option');
opt.value=mid;
opt.textContent=m.label||m.id;
opt.title='Live model — fetched from provider';
providerGroup.appendChild(opt);
_dynamicModelLabels[mid]=m.label||m.id;
added++;
}
if(added>0){ if(added>0){
// Restore selection
if(currentVal) _applyModelToDropdown(currentVal, sel);
if(typeof syncModelChip==='function') syncModelChip(); if(typeof syncModelChip==='function') syncModelChip();
console.log('[hermes] Live models loaded for',provider+':',added,'new models added'); console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
} }

View File

@@ -309,11 +309,14 @@ class TestProviderIdInGroupResponse:
def test_fetch_live_models_prefers_data_provider_match(self): def test_fetch_live_models_prefers_data_provider_match(self):
src = read("static/ui.js") src = read("static/ui.js")
m = re.search(r'function _fetchLiveModels\b.*?\n\}', src, re.DOTALL) # Live model optgroup matching was extracted to _addLiveModelsToSelect (#872)
assert m, "_fetchLiveModels not found" m = re.search(r'function _addLiveModelsToSelect\b.*?\n\}', src, re.DOTALL)
if not m:
m = re.search(r'function _fetchLiveModels\b.*?\n\}', src, re.DOTALL)
assert m, "_addLiveModelsToSelect or _fetchLiveModels not found"
fn = m.group(0) fn = m.group(0)
assert 'og.dataset.provider' in fn, ( assert 'og.dataset.provider' in fn, (
"_fetchLiveModels must check og.dataset.provider===provider before " "_addLiveModelsToSelect must check og.dataset.provider===provider before "
"falling back to label substring match" "falling back to label substring match"
) )
# The data-provider check must come before the label.includes check # The data-provider check must come before the label.includes check

View File

@@ -23,8 +23,11 @@ class TestLiveModelPrefix:
`minimax/minimax-m2.7` from Nous's live catalog — excluding them `minimax/minimax-m2.7` from Nous's live catalog — excluding them
leaves the bug unfixed.""" leaves the bug unfixed."""
js = _read("static/ui.js") js = _read("static/ui.js")
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL) # Live model prefix logic was extracted to _addLiveModelsToSelect (#872)
assert m, "_fetchLiveModels not found" m = re.search(r'function _addLiveModelsToSelect\(.*?\n\}', js, re.DOTALL)
if not m:
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
assert m, "_addLiveModelsToSelect or _fetchLiveModels not found"
fn = m.group(0) fn = m.group(0)
# The prefix application block must NOT have `!mid.includes('/')` # The prefix application block must NOT have `!mid.includes('/')`
# as a guard — slash-prefixed IDs from portal providers also need # as a guard — slash-prefixed IDs from portal providers also need
@@ -47,7 +50,10 @@ class TestLiveModelPrefix:
so the prefix is ADDED when the flag is true (portal fetch), not when so the prefix is ADDED when the flag is true (portal fetch), not when
false. Earlier revision used `!_needsPrefix` (inverted).""" false. Earlier revision used `!_needsPrefix` (inverted)."""
js = _read("static/ui.js") js = _read("static/ui.js")
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL) # Live model prefix logic was extracted to _addLiveModelsToSelect (#872)
m = re.search(r'function _addLiveModelsToSelect\(.*?\n\}', js, re.DOTALL)
if not m:
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
assert m assert m
fn = m.group(0) fn = m.group(0)
# New flag: _isPortalFetch (positive semantics) # New flag: _isPortalFetch (positive semantics)
@@ -66,7 +72,10 @@ class TestLiveModelPrefix:
"""OpenRouter IDs are cross-namespace by design, and `custom` providers """OpenRouter IDs are cross-namespace by design, and `custom` providers
use user-defined bare names — neither should get a `@provider:` prefix.""" use user-defined bare names — neither should get a `@provider:` prefix."""
js = _read("static/ui.js") js = _read("static/ui.js")
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL) # Live model prefix logic was extracted to _addLiveModelsToSelect (#872)
m = re.search(r'function _addLiveModelsToSelect\(.*?\n\}', js, re.DOTALL)
if not m:
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
assert m assert m
fn = m.group(0) fn = m.group(0)
assert "_ap!=='openrouter'" in fn or "_ap !== 'openrouter'" in fn, ( assert "_ap!=='openrouter'" in fn or "_ap !== 'openrouter'" in fn, (