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:
Maxim Dolgolyov
2026-06-04 21:28:34 +03:00
parent f1f79335ec
commit 6e0a00fd8b
4 changed files with 125 additions and 11 deletions
+68 -2
View File
@@ -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,
};