fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375) - api/streaming.py: detect empty agent response (_assistant_added check), emit apperror(type='no_response' or 'auth_mismatch') instead of silent done - api/streaming.py: add _token_sent flag so guard works for streaming agents - static/messages.js: done handler belt-and-suspenders guard for zero replies - static/messages.js: apperror handler labels 'no_response' type distinctly - api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and _PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini) - api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models from provider API with B310 scheme check + SSRF guard - static/ui.js: _fetchLiveModels() background fetch after static list loads, appends new models to dropdown, caches per session, skips unsupported providers Other: - tests/test_issues_373_374_375.py: 25 new structural tests - tests/test_regressions.py: extend done-handler window 1500->2500 chars - CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922) * fix: SSRF hostname bypass + auth detection operator precedence 1. routes.py: SSRF guard used substring matching (any(k in hostname)) which allows bypass via hostnames like evil-ollama.attacker.com. Changed to exact hostname matching against a fixed set of known local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1). 2. streaming.py: _is_auth detection had a Python operator precedence bug on the ternary expression. The line: 'AuthenticationError' in type(...).__name__ if _last_err else False parsed as the ternary absorbing the rest of the or-chain when _last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: fix v0.50.20 CHANGELOG version number and test count (949 tests) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
57
static/ui.js
57
static/ui.js
@@ -68,6 +68,9 @@ async function populateModelDropdown(){
|
||||
_applyModelToDropdown(data.default_model, sel);
|
||||
}
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
// Kick off a background live-model fetch for the active provider.
|
||||
// This runs after the static list is already shown (no blocking flicker).
|
||||
if(data.active_provider) _fetchLiveModels(data.active_provider, sel);
|
||||
}catch(e){
|
||||
// API unavailable -- keep the hardcoded HTML options as fallback
|
||||
console.warn('Failed to load models from server:',e.message);
|
||||
@@ -75,6 +78,60 @@ async function populateModelDropdown(){
|
||||
}
|
||||
}
|
||||
|
||||
// Cache so we don't re-fetch on every page load
|
||||
const _liveModelCache={};
|
||||
|
||||
async function _fetchLiveModels(provider, sel){
|
||||
if(!provider||!sel) return;
|
||||
// Don't fetch for providers where we know it's unsupported or unnecessary
|
||||
if(['anthropic','google','gemini'].includes(provider)) return;
|
||||
if(_liveModelCache[provider]) return; // already fetched this session
|
||||
try{
|
||||
const url=new URL('/api/models/live',location.origin);
|
||||
url.searchParams.set('provider',provider);
|
||||
const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json());
|
||||
if(!data.models||!data.models.length) return;
|
||||
_liveModelCache[provider]=data.models;
|
||||
// Remember current selection before rebuilding options
|
||||
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')){
|
||||
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;
|
||||
for(const m of data.models){
|
||||
if(existingIds.has(m.id)) continue; // already shown from static list
|
||||
const opt=document.createElement('option');
|
||||
opt.value=m.id;
|
||||
opt.textContent=m.label||m.id;
|
||||
opt.title='Live model — fetched from provider';
|
||||
providerGroup.appendChild(opt);
|
||||
_dynamicModelLabels[m.id]=m.label||m.id;
|
||||
added++;
|
||||
}
|
||||
if(added>0){
|
||||
// Restore selection
|
||||
if(currentVal) _applyModelToDropdown(currentVal, sel);
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
|
||||
}
|
||||
}catch(e){
|
||||
console.debug('[hermes] Live model fetch failed for',provider,e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given model ID belongs to a different provider than the one
|
||||
* currently configured in Hermes. Returns a warning string if mismatched,
|
||||
|
||||
Reference in New Issue
Block a user