feat(provider): add lazy Featherless model discovery

This commit is contained in:
Alexandre Teixeira
2026-09-22 13:12:19 +01:00
parent 2b2bf0fb90
commit 8bb48780a1
6 changed files with 1194 additions and 15 deletions
+1
View File
@@ -2314,6 +2314,7 @@
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
<option value="copilot" data-logo="github" data-auth-flow="copilot">GitHub Copilot</option>
<option value="chatgpt-subscription" data-logo="openai" data-auth-flow="chatgpt-subscription">ChatGPT Subscription</option>
<option value="https://api.featherless.ai/v1" data-logo="featherless">Featherless.ai</option>
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
+310 -3
View File
@@ -637,6 +637,298 @@ function endpointDetailHtml(ep, category) {
return `<div class="admin-ep-detail">${parts.join('')}</div>`;
}
function renderFeatherlessPanel(panel, ep, row) {
const epId = ep.id;
const initialPinned = Array.isArray(ep.pinned_models)
? ep.pinned_models
: (typeof ep.pinned_models === 'string' ? JSON.parse(ep.pinned_models || '[]') : []);
const enabledSet = new Set(initialPinned);
const toolModes = typeof ep.model_tool_modes === 'object' && ep.model_tool_modes !== null
? { ...ep.model_tool_modes }
: {};
panel.dataset.pickerMode = 'pinned';
panel.innerHTML = `<div class="mcp-tools-header">
<span>Featherless Catalog</span>
</div>
<div class="featherless-panel" style="display:flex;flex-direction:column;gap:12px;padding:6px 0;">
<div class="featherless-search-bar" style="position:relative;display:flex;align-items:center;">
<input type="search" class="mcp-tools-search featherless-search-input" placeholder="Search Featherless models (min 2 chars)..." style="width:100%;box-sizing:border-box;" data-featherless-search="${esc(epId)}">
<span class="featherless-spinner-host" style="display:none;position:absolute;right:8px;font-size:10px;opacity:0.55;">Searching...</span>
</div>
<div class="featherless-enabled-section">
<div style="font-size:11px;font-weight:600;opacity:0.8;margin-bottom:4px;">
Enabled models (<span class="featherless-enabled-count">${enabledSet.size}</span>)
</div>
<div class="featherless-enabled-list mcp-tools-list" style="max-height:160px;overflow-y:auto;"></div>
</div>
<div class="featherless-results-section">
<div style="font-size:11px;font-weight:600;opacity:0.8;margin-bottom:4px;">
Search results
</div>
<div class="featherless-results-list mcp-tools-list" style="max-height:280px;overflow-y:auto;">
<span class="featherless-search-hint" style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">Type at least 2 characters to search over 20,000+ models.</span>
</div>
<div class="featherless-pagination" style="display:none;margin-top:6px;text-align:center;">
<button type="button" class="admin-btn-sm featherless-load-more" style="width:100%;">Load more</button>
</div>
</div>
</div>`;
const searchInput = panel.querySelector('.featherless-search-input');
const spinnerHost = panel.querySelector('.featherless-spinner-host');
const enabledListEl = panel.querySelector('.featherless-enabled-list');
const enabledCountSpan = panel.querySelector('.featherless-enabled-count');
const resultsList = panel.querySelector('.featherless-results-list');
const paginationHost = panel.querySelector('.featherless-pagination');
const loadMoreBtn = panel.querySelector('.featherless-load-more');
const showSpinner = () => { if (spinnerHost) spinnerHost.style.display = 'inline-flex'; };
const hideSpinner = () => { if (spinnerHost) spinnerHost.style.display = 'none'; };
const formatTokens = (tokens) => {
if (!tokens || typeof tokens !== 'number') return '';
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}M`;
if (tokens >= 1000) return `${Math.round(tokens / 1024)}k`;
return String(tokens);
};
const updateHeaderCount = () => {
const countBadge = row ? row.querySelector(`[data-adm-ep-models-count="${epId}"]`) : null;
if (countBadge) {
countBadge.textContent = `${enabledSet.size} models enabled`;
}
if (enabledCountSpan) {
enabledCountSpan.textContent = String(enabledSet.size);
}
ep.pinned_models = Array.from(enabledSet);
};
const saveState = async () => {
try {
await fetch(`/api/model-endpoints/${epId}/models`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
pinned_models: Array.from(enabledSet),
model_tool_modes: toolModes,
}),
});
if (typeof _refreshAfterEndpointChange === 'function') {
_refreshAfterEndpointChange();
}
} catch (err) {
console.error('Failed to save Featherless model state', err);
}
};
const syncSearchCheckboxes = () => {
resultsList.querySelectorAll('input[data-featherless-search-id]').forEach(cb => {
const id = cb.dataset.featherlessSearchId;
cb.checked = enabledSet.has(id);
});
};
const renderEnabledList = () => {
if (!enabledListEl) return;
if (enabledSet.size === 0) {
enabledListEl.innerHTML = '<span style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">No models enabled. Search below to add models.</span>';
return;
}
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
enabledListEl.innerHTML = sortedIds.map(id => {
const displayName = id.split('/').pop() || id;
const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase())
? String(toolModes[id]).toLowerCase()
: '';
return `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
<label class="adm-model-label">
<input type="checkbox" class="adm-cb-hidden" data-featherless-enabled-id="${esc(id)}" checked>
<span class="adm-check-dot" aria-hidden="true"></span>
<span class="adm-model-name">${esc(displayName)}</span>
</label>
<div class="adm-model-tools-col">
<span class="adm-model-tools-label" title="Select the tool schema profile for this model">Tools</span>
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
</div>`;
}).join('');
enabledListEl.querySelectorAll('input[data-featherless-enabled-id]').forEach(cb => {
cb.addEventListener('change', () => {
const id = cb.dataset.featherlessEnabledId;
if (!cb.checked) {
enabledSet.delete(id);
updateHeaderCount();
renderEnabledList();
syncSearchCheckboxes();
saveState();
}
});
});
enabledListEl.querySelectorAll('.adm-model-tool-mode').forEach(sel => {
sel.addEventListener('change', () => {
const id = sel.dataset.epModelId;
const val = String(sel.value || '').toLowerCase();
if (val) toolModes[id] = val;
else delete toolModes[id];
saveState();
});
});
};
renderEnabledList();
let currentQuery = '';
let currentPage = 1;
let searchAbortController = null;
let searchTimeout = null;
let isSearching = false;
const renderSearchResults = (items, append = false, hasMore = false) => {
if (!append) {
resultsList.innerHTML = '';
}
if (!items || items.length === 0) {
if (!append) {
resultsList.innerHTML = '<span style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">No models found matching your search.</span>';
}
if (paginationHost) paginationHost.style.display = 'none';
return;
}
const itemsHtml = items.map(item => {
const isChecked = enabledSet.has(item.id);
const displayName = item.name || item.id;
const ctx = item.context_length ? `${formatTokens(item.context_length)} ctx` : '';
return `<div title="${esc(item.id)}" data-ep-model-row data-model-id="${esc(item.id)}" class="adm-model-row">
<label class="adm-model-label" style="width:100%;">
<input type="checkbox" class="adm-cb-hidden" data-featherless-search-id="${esc(item.id)}" ${isChecked ? 'checked' : ''}>
<span class="adm-check-dot" aria-hidden="true"></span>
<span class="adm-model-name" style="flex:1;">${esc(displayName)}</span>
${ctx ? `<span class="admin-badge" style="margin-left:auto;font-size:9px;opacity:0.6;">${esc(ctx)}</span>` : ''}
</label>
</div>`;
}).join('');
if (append) {
resultsList.insertAdjacentHTML('beforeend', itemsHtml);
} else {
resultsList.innerHTML = itemsHtml;
}
resultsList.querySelectorAll('input[data-featherless-search-id]').forEach(cb => {
if (cb.dataset.listenerAttached) return;
cb.dataset.listenerAttached = '1';
cb.addEventListener('change', () => {
const id = cb.dataset.featherlessSearchId;
if (cb.checked) {
enabledSet.add(id);
} else {
enabledSet.delete(id);
}
updateHeaderCount();
renderEnabledList();
syncSearchCheckboxes();
saveState();
});
});
if (paginationHost) {
paginationHost.style.display = hasMore ? '' : 'none';
}
};
if (searchInput) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
}
});
searchInput.addEventListener('input', () => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
const q = searchInput.value.trim();
if (q.length < 2) {
if (searchAbortController) searchAbortController.abort();
hideSpinner();
resultsList.innerHTML = '<span class="featherless-search-hint" style="opacity:0.5;font-size:11px;padding:4px 0;display:block;">Type at least 2 characters to search over 20,000+ models.</span>';
if (paginationHost) paginationHost.style.display = 'none';
return;
}
if (searchAbortController) {
searchAbortController.abort();
}
searchAbortController = new AbortController();
currentQuery = q;
currentPage = 1;
showSpinner();
try {
const res = await fetch(`/api/model-endpoints/${epId}/catalog-search?q=${encodeURIComponent(q)}&page=1&per_page=50`, {
credentials: 'same-origin',
signal: searchAbortController.signal,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.detail || `HTTP ${res.status}`);
}
const data = await res.json();
renderSearchResults(data.items, false, data.has_more);
} catch (err) {
if (err.name === 'AbortError') return;
resultsList.innerHTML = `<span class="admin-error" style="font-size:11px;padding:4px 0;display:block;">Search failed: ${esc(err.message)}</span>`;
if (paginationHost) paginationHost.style.display = 'none';
} finally {
hideSpinner();
}
}, 250);
});
}
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (!currentQuery || isSearching) return;
isSearching = true;
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading...';
currentPage += 1;
try {
const res = await fetch(`/api/model-endpoints/${epId}/catalog-search?q=${encodeURIComponent(currentQuery)}&page=${currentPage}&per_page=50`, {
credentials: 'same-origin',
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.detail || `HTTP ${res.status}`);
}
const data = await res.json();
renderSearchResults(data.items, true, data.has_more);
} catch (err) {
if (typeof uiModule !== 'undefined' && uiModule?.showToast) {
uiModule.showToast(`Failed to load more models: ${err.message}`, 4000);
}
} finally {
isSearching = false;
loadMoreBtn.disabled = false;
loadMoreBtn.textContent = 'Load more';
}
});
}
}
// ChatGPT per-endpoint usage panel expanded state persistence.
// Preserves only endpoint/auth identifiers, never tokens, secrets, or labels.
const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded';
@@ -737,7 +1029,7 @@ async function loadEndpoints() {
const statusBadge = ep.status === 'empty'
? '<span class="admin-badge">no models</span>'
: ep.online
? `<span class="admin-badge">${countText}</span>`
? `<span class="admin-badge" data-adm-ep-models-count="${ep.id}">${countText}</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');
@@ -961,7 +1253,7 @@ async function loadEndpoints() {
// Don't let interactions inside the expanded panel re-fire the
// expand/collapse handler — the search box was getting closed
// because clicking it bubbled up to here.
if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, select, label')) return;
if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, select, label, button, .featherless-panel, .featherless-search-bar, [data-ep-model-row]')) return;
const epId = header.dataset.admEpHeader;
const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`);
if (!panel) return;
@@ -974,6 +1266,11 @@ async function loadEndpoints() {
}
if (!_modelsLoaded && isOpen) {
_modelsLoaded = true;
const ep = data.find(x => String(x.id) === String(epId));
if (ep && isFeatherlessEndpoint(ep)) {
renderFeatherlessPanel(panel, ep, row);
return;
}
// Our shared whirlpool spinner (consistent with the rest of the app).
panel.innerHTML = '';
let _modelsSpin = null;
@@ -1509,6 +1806,12 @@ function initEndpointForm() {
}
function _renderEndpointTestResult(msg, res, d) {
const isFeatherless = d && d.base_url && /featherless\.ai/i.test(d.base_url);
if (res.ok && isFeatherless && d.online) {
msg.textContent = 'Online — Featherless.ai catalog ready (search to enable models)';
msg.className = 'admin-success';
return;
}
if (res.ok && d.status === 'empty') {
msg.textContent = 'Online — no models found';
msg.className = 'admin-success';
@@ -1630,9 +1933,13 @@ function initEndpointForm() {
await loadEndpoints();
await _selectAddedModelInChat(d);
const goLink = ' <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>';
const isFeatherless = d && d.base_url && /featherless\.ai/i.test(d.base_url);
if (!d.online) {
msg.innerHTML = 'Added (endpoint offline — will retry on next load)' + goLink;
msg.className = 'admin-error';
} else if (isFeatherless) {
msg.innerHTML = 'Added Featherless.ai — search catalog to enable models' + goLink;
msg.className = 'admin-success';
} else if (d.status === 'empty') {
msg.innerHTML = 'Added — endpoint reachable, no models found' + goLink;
msg.className = 'admin-success';
@@ -3809,7 +4116,7 @@ export function close() {
settingsModule.close();
}
export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml };
export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml, renderFeatherlessPanel };
const adminModule = { open, close, _initData, get _initialized() { return initialized; } };
export default adminModule;
+4
View File
@@ -87,6 +87,9 @@ const _PROVIDERS = [
// NVIDIA / Nemotron (official Simple Icons)
[/nvidia|nemotron/i,
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"/></svg>'],
// Featherless AI (feather icon)
[/featherless/i,
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z"/><line x1="16" y1="8" x2="2" y2="22"/><line x1="17.5" y1="15" x2="9" y2="15"/></svg>'],
];
// Returns an SVG string for the given model ID, or null if no match
@@ -107,6 +110,7 @@ export function providerLogo(modelId) {
const _ENDPOINT_LABELS = [
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
[/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"],
[/(^|\.)featherless\.ai$/i, "Featherless.ai"],
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
[/(^|\.)anthropic\.com$/i, "Anthropic"],
[/(^|\.)openai\.com$/i, "OpenAI"],