mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 15:32:21 +02:00
feat(provider): add lazy Featherless model discovery
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user