diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js
index 47ba6a5..29ac790 100644
--- a/backend/src/controllers/adminController.js
+++ b/backend/src/controllers/adminController.js
@@ -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,
};
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js
index 561e26d..17cd9db 100644
--- a/backend/src/routes/admin.js
+++ b/backend/src/routes/admin.js
@@ -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);
diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js
index 12c0893..88f845c 100644
--- a/frontend/js/admin/sections/assistant.js
+++ b/frontend/js/admin/sections/assistant.js
@@ -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 = '';
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 = 'контекст ' + fmtTok(L.ctx) + ' · ответ до ' + fmtTok(L.out) + ' токенов'; if (L.free === true) s += ' · бесплатно'; else if (L.free === false) s += ' · платно'; return s; }
function ensureStyle() {
if (document.getElementById('asst-adm-style')) return;
@@ -100,6 +101,8 @@
'
URL (chat/completions)
' +
'
Модель Kilo (бесплатные)
' +
'
Модель
' +
+ '
' +
+ '' +
'
API-ключ
' +
'' +
'';
@@ -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 = '';
- var km = kiloModels.find(function (m) { return m.id === p.model; });
- if (km) lim = '
контекст ' + fmtTok(km.ctx) + ' · ответ до ' + fmtTok(km.out) + ' токенов · бесплатно
';
}
+ // лимиты: сохранённые на провайдере → для 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
+ ? '