fix(models): stabilize proxy endpoint refresh behavior

* fix: support large proxy model endpoint refresh

Large OpenAI-compatible proxy endpoints can expose hundreds of models and make /v1/models slow. Treating those endpoints like local model servers caused model picker opens and background probes to repeatedly hit /models, producing timeouts and making otherwise usable endpoints appear offline.

Make model endpoint discovery cached-first for normal UI usage, add explicit proxy/API classification and refresh policy fields, exclude proxy/API endpoints from aggressive local probing, and preserve cached models when refresh fails.

Manual Test/Add/Refresh actions still fetch the full model list with longer timeouts so users can intentionally import large proxy model lists without blocking normal model picker usage.

* fix: preserve endpoint ping status semantics
This commit is contained in:
Yuri
2026-06-04 00:56:11 -03:00
committed by GitHub
parent eee2167502
commit a2e691da2b
10 changed files with 1323 additions and 231 deletions

View File

@@ -2079,6 +2079,10 @@
</select>
<div class="admin-model-form-row">
<input id="adm-epApiKey" type="password" placeholder="API key">
<select id="adm-epKind" style="padding:5px;width:82px;">
<option value="proxy">Proxy</option>
<option value="api">API</option>
</select>
<select id="adm-epType" style="padding:5px;width:80px;">
<option value="llm">LLM</option>
<option value="image">Image</option>

View File

@@ -371,7 +371,7 @@ async function loadEndpoints() {
const listLegacy = el('adm-epList');
// Refresh model picker so new endpoints show up in chat
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(true);
window.modelsModule.refreshModels();
setTimeout(() => {
if (window.sessionModule && window.sessionModule.updateModelPicker) {
window.sessionModule.updateModelPicker();
@@ -411,12 +411,15 @@ async function loadEndpoints() {
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
: '<span class="admin-badge admin-badge-off">offline</span>';
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api');
const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : '';
return `
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
<span class="admin-user-name">${esc(ep.name)}</span>
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge}
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
@@ -427,7 +430,7 @@ async function loadEndpoints() {
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}
</div>
</div>
<div class="admin-ep-detail">${esc(ep.base_url)}${_isLocalEndpoint(ep.base_url) ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
</div>`;
});
@@ -446,7 +449,7 @@ async function loadEndpoints() {
container.innerHTML = indices.map(i => rowHtml[i]).join('');
};
const localIdx = [], apiIdx = [];
data.forEach((ep, i) => (_isLocalEndpoint(ep.base_url) ? localIdx : apiIdx).push(i));
data.forEach((ep, i) => ((ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api')) === 'local' ? localIdx : apiIdx).push(i));
// Sort each section: enabled endpoints first, disabled at the bottom.
// Preserve original order within each group via stable sort.
const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled);
@@ -552,22 +555,48 @@ async function loadEndpoints() {
} catch (_) {}
panel.appendChild(_ld);
const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} };
try {
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
const models = await res.json();
_stopSpin();
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
const renderModels = (models, warning = '') => {
const sortedModels = sortModelObjects(models);
if (!sortedModels.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
const warningHtml = warning ? `<div class="admin-error" style="font-size:11px;margin:6px 0;">${esc(warning)}</div>` : '';
const attachRefresh = () => {
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
e.preventDefault();
panel.innerHTML = _loadingHtml('Refreshing models...');
try {
const res = await fetch(`/api/model-endpoints/${epId}/models?refresh=true&refresh_timeout=60`, { credentials: 'same-origin' });
const refreshWarning = res.headers.get('X-Model-Refresh-Warning') || '';
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const refreshedModels = await res.json();
renderModels(refreshedModels, refreshWarning);
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
} catch (_) {
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
}
});
};
if (!sortedModels.length) {
panel.innerHTML = `<div class="mcp-tools-header">
<span>Models</span>
<span style="display:flex;gap:8px;align-items:center;">
<span class="mcp-tools-count">0/0 enabled</span>
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
</span>
</div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`;
attachRefresh();
return;
}
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
const showSearch = sortedModels.length >= 8;
panel.innerHTML = `<div class="mcp-tools-header">
<span>Models</span>
<span style="display:flex;gap:8px;align-items:center;">
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
<a href="#" data-ep-select-all="${epId}">All</a>
<a href="#" data-ep-select-none="${epId}">None</a>
</span>
</div>${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
<span class="adm-check-dot" aria-hidden="true"></span>
@@ -580,6 +609,7 @@ async function loadEndpoints() {
row.style.display = (!needle || row.dataset.search.includes(needle)) ? '' : 'none';
});
};
attachRefresh();
panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value));
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
@@ -598,6 +628,13 @@ async function loadEndpoints() {
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => _saveEpModelState(epId, panel));
});
};
try {
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const models = await res.json();
_stopSpin();
renderModels(models);
} catch (e) { _stopSpin(); panel.innerHTML = '<span class="admin-error" style="font-size:11px;">Failed to load models</span>'; }
}
});
@@ -637,6 +674,7 @@ async function _saveEpModelState(epId, panel) {
function initEndpointForm() {
const provider = el('adm-epProvider');
const urlInput = el('adm-epUrl');
const kindSel = el('adm-epKind');
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
// so the rest of this function (which reads provider.value and dispatches
@@ -688,14 +726,20 @@ function initEndpointForm() {
provider.addEventListener('change', () => {
if (provider.value) urlInput.value = provider.value;
else urlInput.value = '';
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
});
urlInput.addEventListener('input', () => {
if (provider.value && urlInput.value.trim() !== provider.value) {
provider.value = '';
if (kindSel) kindSel.value = 'proxy';
_renderPickerMenu();
_syncPickerCurrent();
}
});
if (kindSel) kindSel.value = provider.value ? 'api' : (kindSel.value || 'proxy');
function _apiEndpointKind() {
return (kindSel && kindSel.value) ? kindSel.value : (provider.value ? 'api' : 'proxy');
}
function _normalizeBaseUrl(raw) {
let u = raw.trim();
// Fix common protocol typos
@@ -784,6 +828,8 @@ function initEndpointForm() {
try {
const fd = new FormData();
fd.append('base_url', url);
fd.append('endpoint_kind', _apiEndpointKind());
fd.append('model_refresh_timeout', '30');
if (apiKey) fd.append('api_key', apiKey);
const res = await fetch('/api/model-endpoints/test', {
method: 'POST',
@@ -828,6 +874,10 @@ function initEndpointForm() {
try {
const fd = new FormData();
fd.append('base_url', url);
const endpointKind = _apiEndpointKind();
fd.append('endpoint_kind', endpointKind);
fd.append('model_refresh_mode', endpointKind === 'proxy' ? 'manual' : 'auto');
fd.append('model_refresh_timeout', '30');
if (apiKey) fd.append('api_key', apiKey);
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
fd.append('name', provider.selectedOptions[0].textContent.trim());
@@ -842,6 +892,7 @@ function initEndpointForm() {
const count = d.models ? d.models.length : 0;
urlInput.value = ''; urlInput.style.display = '';
el('adm-epApiKey').value = ''; provider.value = '';
if (kindSel) kindSel.value = 'proxy';
if (epType) epType.value = 'llm';
if (d.id) _recentlyAddedEpId = String(d.id);
await loadEndpoints();
@@ -904,6 +955,8 @@ function initEndpointForm() {
const fd = new FormData();
fd.append('base_url', url);
if (apiKey) fd.append('api_key', apiKey);
fd.append('endpoint_kind', 'local');
fd.append('model_refresh_mode', 'auto');
const lt = el('adm-epLocalType');
if (lt) fd.append('model_type', lt.value);
fd.append('skip_probe', 'false');
@@ -986,6 +1039,8 @@ function initEndpointForm() {
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
const fd = new FormData();
fd.append('base_url', base);
fd.append('endpoint_kind', 'local');
fd.append('model_refresh_mode', 'auto');
fd.append('skip_probe', 'false');
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
if (r.ok) {

View File

@@ -561,7 +561,7 @@ function _initModelPickerDropdown() {
menu.classList.remove('closing', 'hidden');
_populate('');
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(true).then(() => {
window.modelsModule.refreshModels().then(() => {
if (!menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
}).catch(() => {});

View File

@@ -16,6 +16,7 @@ import { sortModelIds } from './modelSort.js';
let API_BASE = '';
let _cachedItems = []; // cached /api/models items for model-switch dropdown
let _lastFetchTime = 0;
let _fetchInflight = null;
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
const COLLAPSE_KEY = 'odysseus-models-collapsed';
const FAVORITES_KEY = 'odysseus-model-favorites';
@@ -176,8 +177,15 @@ export async function refreshModels(force = false) {
box.appendChild(_loadingSpinner.createElement());
_loadingSpinner.start();
try {
const res = await fetch(`${API_BASE}/api/models`);
const data = await res.json();
if (!_fetchInflight) {
_fetchInflight = fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.finally(() => { _fetchInflight = null; });
}
const data = await _fetchInflight;
_lastFetchTime = Date.now();
_cachedItems = data.items || [];
} catch (e) {