feat(assistant): авто-получение лимитов моделей для любого провайдера
Новый GET /admin/assistant/models: тянет список моделей провайдера с лимитами (OpenAI-совместимый /models: context_length+max_completion_tokens+pricing; нативный Google generativelanguage: inputTokenLimit/outputTokenLimit) и кэширует лимиты текущей модели на провайдере. Карточка показывает лимиты у ВСЕХ провайдеров (не только Kilo), для отсутствующих — фоновая авто-подгрузка. В форме — кнопка «Загрузить модели провайдера» с выбором модели и её лимитами. Так Gemini и любые новые модели получают лимиты автоматически. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -916,7 +916,7 @@ function getAssistant(_req, res) {
|
||||
}
|
||||
}
|
||||
const list = _aProviders();
|
||||
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key }));
|
||||
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) }));
|
||||
const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null;
|
||||
const ap = list.find(p => p.id === activeId);
|
||||
const active = !!(ap && (ap.key || _aIsLocal(ap.url)));
|
||||
@@ -962,11 +962,20 @@ function saveProvider(req, res) {
|
||||
let p;
|
||||
if (b.id) { p = arr.find(x => x.id === b.id); if (!p) { p = { id: b.id }; arr.push(p); } }
|
||||
else { p = { id: 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1000) }; arr.push(p); }
|
||||
const prevModel = p.model;
|
||||
if (typeof b.name === 'string') p.name = b.name.trim().slice(0, 40) || p.name || 'Провайдер';
|
||||
if (typeof b.url === 'string') p.url = b.url.trim().slice(0, 300);
|
||||
if (typeof b.model === 'string') p.model = b.model.trim().slice(0, 120);
|
||||
if (typeof b.key === 'string' && b.key.trim()) p.key = b.key.trim().slice(0, 400);
|
||||
if (!p.name) p.name = 'Провайдер';
|
||||
// Лимиты модели (ctx/out/free): из тела или сброс при смене модели (перезапросятся авто)
|
||||
if (b.ctx !== undefined || b.out !== undefined || b.free !== undefined) {
|
||||
if (b.ctx !== undefined) p.ctx = (b.ctx === null || b.ctx === '') ? null : (Number(b.ctx) || null);
|
||||
if (b.out !== undefined) p.out = (b.out === null || b.out === '') ? null : (Number(b.out) || null);
|
||||
if (b.free !== undefined) p.free = (typeof b.free === 'boolean') ? b.free : null;
|
||||
} else if (typeof b.model === 'string' && p.model !== prevModel) {
|
||||
p.ctx = null; p.out = null; p.free = null;
|
||||
}
|
||||
_aSetProviders(arr);
|
||||
if (b.makeActive || arr.length === 1) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(p.id);
|
||||
audit(req, 'assistant.provider', p.id, 'сохранён');
|
||||
@@ -983,6 +992,63 @@ function deleteProvider(req, res) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* Запрос списка моделей провайдера с лимитами. Понимает OpenAI-совместимый /models
|
||||
* (context_length + max_completion_tokens + pricing) и нативный Google generativelanguage
|
||||
* (inputTokenLimit / outputTokenLimit). */
|
||||
async function _fetchModels(url, key) {
|
||||
if (typeof fetch !== 'function') return { error: 'fetch недоступен' };
|
||||
url = String(url || '');
|
||||
const isGoogle = /generativelanguage\.googleapis\.com/.test(url);
|
||||
let ep; const headers = {};
|
||||
if (isGoogle) {
|
||||
const base = url.replace(/\/openai\/chat\/completions.*$/, '').replace(/\/chat\/completions.*$/, '');
|
||||
ep = base + '/models?pageSize=200' + (key ? '&key=' + encodeURIComponent(key) : '');
|
||||
} else {
|
||||
ep = url.replace(/\/chat\/completions.*$/, '/models');
|
||||
if (key) headers.Authorization = 'Bearer ' + key;
|
||||
}
|
||||
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||
try {
|
||||
const r = await fetch(ep, { headers, signal: ctrl.signal });
|
||||
if (!r.ok) return { error: 'HTTP ' + r.status, status: r.status };
|
||||
const j = await r.json();
|
||||
const raw = j.data || j.models || [];
|
||||
const models = [];
|
||||
for (const m of raw) {
|
||||
const methods = m.supportedGenerationMethods;
|
||||
if (methods && methods.indexOf('generateContent') === -1) continue; // Google: только генеративные
|
||||
const id = String(m.id || m.name || '').replace(/^models\//, '');
|
||||
if (!id) continue;
|
||||
const tp = m.top_provider || {};
|
||||
const pr = m.pricing || {};
|
||||
const ctx = m.context_length || tp.context_length || m.inputTokenLimit || null;
|
||||
const out = tp.max_completion_tokens || m.outputTokenLimit || null;
|
||||
let free = null;
|
||||
if (pr && (pr.prompt != null || pr.completion != null)) free = Number(pr.prompt) === 0 && Number(pr.completion) === 0;
|
||||
models.push({ id, ctx: ctx || null, out: out || null, free });
|
||||
}
|
||||
return { models };
|
||||
} catch (e) { return { error: e.name === 'AbortError' ? 'timeout' : 'network' }; }
|
||||
finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/* GET /api/admin/assistant/models?id=&url=&key= — модели провайдера с лимитами.
|
||||
* С id: берём сохранённого провайдера и кэшируем лимиты его текущей модели. */
|
||||
async function getProviderModels(req, res) {
|
||||
const id = req.query.id ? String(req.query.id) : '';
|
||||
let url = req.query.url, key = req.query.key, prov = null;
|
||||
if (id) { prov = _aProviders().find(x => x.id === id); if (!prov) return res.json({ error: 'провайдер не найден' }); url = prov.url; key = prov.key; }
|
||||
const r = await _fetchModels(url, key);
|
||||
if (r.error) return res.json({ error: r.error, status: r.status });
|
||||
let current = null;
|
||||
if (prov) {
|
||||
const arr = _aProviders(); const p2 = arr.find(x => x.id === id);
|
||||
const m = p2 && r.models.find(x => x.id === p2.model);
|
||||
if (p2 && m) { p2.ctx = m.ctx; p2.out = m.out; p2.free = m.free; _aSetProviders(arr); current = { ctx: m.ctx, out: m.out, free: m.free }; }
|
||||
}
|
||||
res.json({ models: r.models, current });
|
||||
}
|
||||
|
||||
/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */
|
||||
function setActiveProvider(req, res) {
|
||||
const id = String((req.body && req.body.id) || '');
|
||||
@@ -1036,5 +1102,5 @@ module.exports = {
|
||||
getSecurityLog, clearSecurityLog,
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
broadcast,
|
||||
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider,
|
||||
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ router.get('/assistant', ctrl.getAssistant);
|
||||
router.put('/assistant', ctrl.saveAssistant);
|
||||
router.post('/assistant/test', ctrl.testAssistant);
|
||||
router.post('/assistant/reindex', ctrl.reindexTextbooks);
|
||||
router.get('/assistant/models', ctrl.getProviderModels);
|
||||
router.post('/assistant/provider', ctrl.saveProvider);
|
||||
router.delete('/assistant/provider/:id', requireRole('admin'), ctrl.deleteProvider);
|
||||
router.post('/assistant/active', ctrl.setActiveProvider);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
var IN = 'padding:8px 11px;border:1px solid var(--border,#e2e8f0);border-radius:9px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)';
|
||||
var SPARK = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>';
|
||||
function fmtTok(n) { if (!n) return '—'; if (n >= 1000000) { var m = n / 1000000; return (m >= 10 ? Math.round(m) : m.toFixed(1).replace(/\.0$/, '')) + 'M'; } if (n >= 1000) return Math.round(n / 1000) + 'K'; return String(n); }
|
||||
function fmtLimits(L) { var s = 'контекст <b>' + fmtTok(L.ctx) + '</b> · ответ до <b>' + fmtTok(L.out) + '</b> токенов'; if (L.free === true) s += ' · <b>бесплатно</b>'; else if (L.free === false) s += ' · платно'; return s; }
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById('asst-adm-style')) return;
|
||||
@@ -100,6 +101,8 @@
|
||||
'<div class="asst-flabel">URL (chat/completions)</div><input id="asst-url" placeholder="https://…/chat/completions" style="' + IN + '" />' +
|
||||
'<div id="asst-kbox" style="display:none"><div class="asst-flabel">Модель Kilo (бесплатные)</div><select id="asst-kmodels" style="' + IN + '"></select></div>' +
|
||||
'<div class="asst-flabel">Модель</div><input id="asst-model" placeholder="model-id" style="' + IN + '" />' +
|
||||
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button id="asst-fetch" type="button" class="asst-ib">Загрузить модели провайдера</button><span id="asst-fetch-st" style="font-size:.74rem;color:#8a94a6"></span></div>' +
|
||||
'<select id="asst-fetched" style="display:none;' + IN + '"></select>' +
|
||||
'<div class="asst-flabel">API-ключ</div><input id="asst-key" type="password" autocomplete="off" placeholder="ключ" style="' + IN + '" />' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px"><button id="asst-save" class="asst-ib primary" style="padding:8px 16px">Сохранить провайдера</button><button id="asst-cancel" class="asst-ib" style="padding:8px 16px">Отмена</button></div>' +
|
||||
'</div>';
|
||||
@@ -127,14 +130,18 @@
|
||||
else listEl.innerHTML = providers.map(function (p) {
|
||||
var isKilo = /kilocode\.ai/.test(p.url || '');
|
||||
var act = p.id === activeId;
|
||||
var ksel = '', lim = '';
|
||||
var ksel = '';
|
||||
if (isKilo) {
|
||||
var opts = kiloModels.slice();
|
||||
if (!opts.some(function (m) { return m.id === p.model; })) opts = [{ id: p.model, label: p.model }].concat(opts);
|
||||
ksel = '<select class="asst-ksel" data-ksel="' + p.id + '">' + opts.map(function (m) { return '<option value="' + esc(m.id) + '"' + (m.id === p.model ? ' selected' : '') + '>' + esc(m.label) + '</option>'; }).join('') + '</select>';
|
||||
var km = kiloModels.find(function (m) { return m.id === p.model; });
|
||||
if (km) lim = '<div class="asst-pclim">контекст <b>' + fmtTok(km.ctx) + '</b> · ответ до <b>' + fmtTok(km.out) + '</b> токенов · <b>бесплатно</b></div>';
|
||||
}
|
||||
// лимиты: сохранённые на провайдере → для Kilo из списка → иначе авто-подгрузка
|
||||
var L = p.ctx ? { ctx: p.ctx, out: p.out, free: p.free } : null;
|
||||
if (!L && isKilo) { var km = kiloModels.find(function (m) { return m.id === p.model; }); if (km) L = { ctx: km.ctx, out: km.out, free: true }; }
|
||||
var lim = L
|
||||
? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</div>'
|
||||
: '<div class="asst-pclim" data-lim="' + p.id + '" style="opacity:.6">лимиты: загрузка…</div>';
|
||||
return '<div class="asst-pcard' + (act ? ' active' : '') + '">' +
|
||||
'<div class="asst-pcic">' + SPARK + '</div>' +
|
||||
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') +
|
||||
@@ -149,8 +156,20 @@
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
|
||||
// авто-подгрузка лимитов для провайдеров без сохранённых (Gemini, новые модели) — фоном, сервер кэширует
|
||||
var _pickedLimits = null;
|
||||
providers.forEach(function (p) {
|
||||
if (p.ctx) return;
|
||||
if (/kilocode\.ai/.test(p.url || '') && kiloModels.some(function (m) { return m.id === p.model; })) return;
|
||||
LS.adminAssistantModels({ id: p.id }).then(function (r) {
|
||||
var el = host.querySelector('[data-lim="' + p.id + '"]'); if (!el) return;
|
||||
if (r && !r.error && r.current) { el.style.opacity = ''; el.innerHTML = fmtLimits(r.current); }
|
||||
else { el.style.display = 'none'; }
|
||||
}).catch(function () { var el = host.querySelector('[data-lim="' + p.id + '"]'); if (el) el.style.display = 'none'; });
|
||||
});
|
||||
|
||||
function openForm(show) { Q('#asst-form').style.display = show ? 'flex' : 'none'; }
|
||||
function clearForm() { editingId = null; Q('#asst-fhead').textContent = 'Новый провайдер'; Q('#asst-preset').value = ''; Q('#asst-name').value = ''; Q('#asst-url').value = ''; Q('#asst-model').value = ''; Q('#asst-key').value = ''; Q('#asst-key').placeholder = 'ключ'; toggleKilo(); openForm(false); }
|
||||
function clearForm() { editingId = null; _pickedLimits = null; Q('#asst-fhead').textContent = 'Новый провайдер'; Q('#asst-preset').value = ''; Q('#asst-name').value = ''; Q('#asst-url').value = ''; Q('#asst-model').value = ''; Q('#asst-key').value = ''; Q('#asst-key').placeholder = 'ключ'; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetched').innerHTML = ''; Q('#asst-fetch-st').textContent = ''; toggleKilo(); openForm(false); }
|
||||
function toggleKilo() {
|
||||
var isKilo = /kilocode\.ai/.test(Q('#asst-url').value || '');
|
||||
Q('#asst-kbox').style.display = isKilo ? '' : 'none';
|
||||
@@ -163,7 +182,10 @@
|
||||
// карточные действия
|
||||
listEl.querySelectorAll('[data-ksel]').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
LS.adminSaveProvider({ id: sel.getAttribute('data-ksel'), model: sel.value }).then(function () { LS.toast('Модель обновлена', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
|
||||
var km = kiloModels.find(function (m) { return m.id === sel.value; });
|
||||
var body = { id: sel.getAttribute('data-ksel'), model: sel.value };
|
||||
if (km) { body.ctx = km.ctx; body.out = km.out; body.free = true; }
|
||||
LS.adminSaveProvider(body).then(function () { LS.toast('Модель обновлена', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
|
||||
});
|
||||
});
|
||||
listEl.querySelectorAll('[data-act]').forEach(function (b) {
|
||||
@@ -186,14 +208,38 @@
|
||||
|
||||
// форма
|
||||
Q('#asst-ftoggle').addEventListener('click', function () { var open = Q('#asst-form').style.display !== 'none'; if (open) { clearForm(); } else { clearForm(); openForm(true); Q('#asst-name').focus(); } });
|
||||
Q('#asst-preset').addEventListener('change', function () { var p = presets[Number(this.value)]; if (p) { Q('#asst-url').value = p.url; Q('#asst-model').value = p.model; if (!Q('#asst-name').value) Q('#asst-name').value = p.name; toggleKilo(); } });
|
||||
Q('#asst-url').addEventListener('input', toggleKilo);
|
||||
Q('#asst-kmodels').addEventListener('change', function () { Q('#asst-model').value = this.value; });
|
||||
Q('#asst-preset').addEventListener('change', function () { var p = presets[Number(this.value)]; if (p) { Q('#asst-url').value = p.url; Q('#asst-model').value = p.model; if (!Q('#asst-name').value) Q('#asst-name').value = p.name; _pickedLimits = null; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetch-st').textContent = ''; toggleKilo(); } });
|
||||
Q('#asst-url').addEventListener('input', function () { toggleKilo(); _pickedLimits = null; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetch-st').textContent = ''; });
|
||||
Q('#asst-kmodels').addEventListener('change', function () { Q('#asst-model').value = this.value; var km = kiloModels.find(function (m) { return m.id === Q('#asst-model').value; }); _pickedLimits = km ? { ctx: km.ctx, out: km.out, free: true } : null; });
|
||||
Q('#asst-cancel').addEventListener('click', clearForm);
|
||||
Q('#asst-fetch').addEventListener('click', async function () {
|
||||
var st = Q('#asst-fetch-st'), btn = this;
|
||||
var params = editingId ? { id: editingId } : {};
|
||||
if (!editingId) { params.url = Q('#asst-url').value.trim(); params.key = Q('#asst-key').value.trim(); if (!params.url) { st.textContent = 'Сначала введите URL'; return; } }
|
||||
btn.disabled = true; st.textContent = 'Загружаю…';
|
||||
try {
|
||||
var r = await LS.adminAssistantModels(params);
|
||||
if (!r || r.error) { st.textContent = 'Ошибка: ' + ((r && (r.error || ('HTTP ' + r.status))) || 'нет данных'); return; }
|
||||
var models = r.models || [];
|
||||
if (!models.length) { st.textContent = 'Моделей не найдено'; return; }
|
||||
var sel = Q('#asst-fetched');
|
||||
sel.innerHTML = '<option value="">— выбрать из ' + models.length + ' моделей —</option>' + models.map(function (m) {
|
||||
return '<option value="' + esc(m.id) + '" data-ctx="' + (m.ctx || '') + '" data-out="' + (m.out || '') + '" data-free="' + (m.free === null || m.free === undefined ? '' : m.free) + '">' + esc(m.id) + ' (' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + (m.free === true ? ', free' : '') + ')</option>';
|
||||
}).join('');
|
||||
sel.style.display = ''; st.textContent = 'Загружено: ' + models.length + ' — выберите модель';
|
||||
} catch (e) { st.textContent = 'Ошибка'; } finally { btn.disabled = false; }
|
||||
});
|
||||
Q('#asst-fetched').addEventListener('change', function () {
|
||||
var o = this.options[this.selectedIndex]; if (!o || !o.value) { _pickedLimits = null; return; }
|
||||
Q('#asst-model').value = o.value;
|
||||
var fr = o.getAttribute('data-free');
|
||||
_pickedLimits = { ctx: Number(o.getAttribute('data-ctx')) || null, out: Number(o.getAttribute('data-out')) || null, free: fr === '' ? null : fr === 'true' };
|
||||
});
|
||||
Q('#asst-save').addEventListener('click', async function () {
|
||||
var body = { name: Q('#asst-name').value, url: Q('#asst-url').value, model: Q('#asst-model').value };
|
||||
if (editingId) body.id = editingId;
|
||||
var k = Q('#asst-key').value.trim(); if (k) body.key = k;
|
||||
if (_pickedLimits) { body.ctx = _pickedLimits.ctx; body.out = _pickedLimits.out; body.free = _pickedLimits.free; }
|
||||
if (!body.url || !body.model) { LS.toast('Заполни URL и модель', 'warn'); return; }
|
||||
try { await LS.adminSaveProvider(body); LS.toast('Сохранено', 'success'); clearForm(); render(); } catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); }
|
||||
});
|
||||
|
||||
@@ -1052,7 +1052,7 @@ window.LS = {
|
||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback,
|
||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider,
|
||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||
fcListDecks, fcCreateDeck, fcAddCard,
|
||||
escapeHtml, esc,
|
||||
parseDate, fmtRelTime, safeHref,
|
||||
@@ -1284,6 +1284,7 @@ async function adminReindexTextbooks() { return req('POST', '/admin/assistant/re
|
||||
async function adminSaveProvider(d) { return req('POST', '/admin/assistant/provider', d); }
|
||||
async function adminDeleteProvider(id) { return req('DELETE', `/admin/assistant/provider/${id}`); }
|
||||
async function adminSetActiveProvider(id) { return req('POST', '/admin/assistant/active', { id }); }
|
||||
async function adminAssistantModels(params) { const q = new URLSearchParams(params || {}).toString(); return req('GET', '/admin/assistant/models' + (q ? '?' + q : '')); }
|
||||
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
|
||||
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
|
||||
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
||||
|
||||
Reference in New Issue
Block a user