freegate 0.6.17 → 0.6.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -1
- package/README.md +19 -7
- package/README.ru.md +21 -7
- package/assets/dashboard-en.gif +0 -0
- package/assets/dashboard-en.png +0 -0
- package/assets/dashboard-models-en.png +0 -0
- package/assets/dashboard-models.png +0 -0
- package/assets/dashboard.gif +0 -0
- package/assets/dashboard.png +0 -0
- package/bin/freegate.js +122 -0
- package/config.example.json +39 -0
- package/lib/cache.js +19 -4
- package/lib/compress.js +85 -0
- package/lib/contextstats.js +2 -2
- package/lib/dashboard.js +378 -135
- package/lib/doctor.js +106 -0
- package/lib/health.js +48 -0
- package/lib/methodology.js +23 -2
- package/lib/modelscan.js +7 -2
- package/lib/onboarding.js +79 -0
- package/lib/setup.js +136 -0
- package/lib/strategy.js +57 -0
- package/lib/taskclassify.js +44 -3
- package/lib/vetting.js +91 -0
- package/lib/websearch.js +75 -0
- package/package.json +1 -1
- package/providers.json +264 -0
- package/server.js +127 -9
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// lib/doctor.js — автономная диагностика Freegate.
|
|
2
|
+
// Читает .env (ключи), валидирует их, смотрит модели из models-db.json и собирает
|
|
3
|
+
// «что включить». Не требует запущенного сервера. Используется `freegate doctor`.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const setup = require('./setup');
|
|
7
|
+
const { ModelDB } = require('./modeldb');
|
|
8
|
+
|
|
9
|
+
const ROOT = path.join(__dirname, '..');
|
|
10
|
+
|
|
11
|
+
// Провайдер, с которого удобно начинать (быстрый старт).
|
|
12
|
+
function easyStartKey() {
|
|
13
|
+
return { envVar: 'PROVIDER_OPENROUTER_APIKEY', name: 'OpenRouter', count: 15 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Сгруппировать провайдеры каталога по envVar (как в init).
|
|
17
|
+
function groupByEnv(catalog) {
|
|
18
|
+
const groups = new Map();
|
|
19
|
+
for (const [name, p] of Object.entries(catalog || {})) {
|
|
20
|
+
if (!p.envVar) continue;
|
|
21
|
+
if (!groups.has(p.envVar)) groups.set(p.envVar, []);
|
|
22
|
+
groups.get(p.envVar).push(name);
|
|
23
|
+
}
|
|
24
|
+
return groups;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadCatalog() {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'providers.json'), 'utf8'));
|
|
30
|
+
} catch { return {}; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadModelDb() {
|
|
34
|
+
try {
|
|
35
|
+
const p = path.join(ROOT, 'models-db.json');
|
|
36
|
+
if (!fs.existsSync(p)) return {};
|
|
37
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
38
|
+
} catch { return {}; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function statusOf(status) {
|
|
42
|
+
return status === 'active' ? 'up' : status === 'user-disabled' ? 'disabled' : status || 'unknown';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Собрать снимок состояния: ключи, каталог, модели.
|
|
46
|
+
function snapshot({ catalog = loadCatalog(), modelDb = loadModelDb(), keysSource = null } = {}) {
|
|
47
|
+
const { keys, envPath } = keysSource || setup.readKeys();
|
|
48
|
+
const groups = groupByEnv(catalog);
|
|
49
|
+
const models = (modelDb.models && typeof modelDb.models === 'object') ? modelDb.models : {};
|
|
50
|
+
|
|
51
|
+
const keyState = {};
|
|
52
|
+
for (const [envVar, providers] of groups) {
|
|
53
|
+
const meta = setup.KEY_GROUPS[envVar];
|
|
54
|
+
keyState[envVar] = {
|
|
55
|
+
envVar,
|
|
56
|
+
name: meta ? (meta.name) : envVar,
|
|
57
|
+
url: meta ? meta.url : '',
|
|
58
|
+
key: (keys[envVar] || ''), // маскируем при выводе
|
|
59
|
+
providers,
|
|
60
|
+
count: providers.length,
|
|
61
|
+
hasKey: !!(keys[envVar] || '').trim(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Модели по статусу + источнику.
|
|
66
|
+
const byStatus = { active: 0, disabled: 0, unknown: 0 };
|
|
67
|
+
const bySource = {};
|
|
68
|
+
const dead = []; // disabled/мёртвые
|
|
69
|
+
const working = []; // active
|
|
70
|
+
for (const [key, m] of Object.entries(models)) {
|
|
71
|
+
const st = statusOf(m.status); // up/disabled/unknown
|
|
72
|
+
const bucket = st === 'up' ? 'active' : st === 'disabled' ? 'disabled' : 'unknown';
|
|
73
|
+
byStatus[bucket] = (byStatus[bucket] || 0) + 1;
|
|
74
|
+
const src = m.source || 'unknown';
|
|
75
|
+
bySource[src] = (bySource[src] || 0) + 1;
|
|
76
|
+
if (st === 'up') working.push(key);
|
|
77
|
+
else if (st === 'disabled' || st === 'unknown') dead.push(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { envPath, keyState, models, byStatus, bySource, working, dead, catalogCount: Object.keys(catalog).length };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// «Что включить»: по аналогии с дашбордом и модель-менеджером.
|
|
84
|
+
// - disabledByUser: user-disabled, но free/локальные — «можно вернуть» (пользователь сам выключил).
|
|
85
|
+
// - deadOrUntested: dead/untested — проверь/удали.
|
|
86
|
+
// - paid: платные-подозрительные (маленький лимит): их держать выключенными.
|
|
87
|
+
function recommend(models) {
|
|
88
|
+
const all = Object.entries(models).map(([key, m]) => ({ key, model: m.model || key, source: m.source || '', status: m.status || 'unknown' }));
|
|
89
|
+
const disabledByUser = [];
|
|
90
|
+
const dead = [];
|
|
91
|
+
const paid = [];
|
|
92
|
+
const local = [];
|
|
93
|
+
for (const m of all) {
|
|
94
|
+
const isLocal = /local|ollama|lmstudio/i.test(m.source);
|
|
95
|
+
const isPaid = /deepseek|glm-5|ox-alpha|minimax-m2/i.test(m.model);
|
|
96
|
+
if (m.status === 'active') continue; // уже работает
|
|
97
|
+
if (m.status === 'dead' || m.status === 'untested') { dead.push(m.key); continue; }
|
|
98
|
+
if (isLocal) { local.push(m.key); continue; }
|
|
99
|
+
if (isPaid) { paid.push(m.key); continue; }
|
|
100
|
+
if (m.status === 'user-disabled') { disabledByUser.push(m.key); continue; }
|
|
101
|
+
// иначе — unknown, не трогаем
|
|
102
|
+
}
|
|
103
|
+
return { disabledByUser, dead, paid, local };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { snapshot, recommend, easyStartKey, groupByEnv };
|
package/lib/health.js
CHANGED
|
@@ -17,6 +17,51 @@ let recent = [];
|
|
|
17
17
|
let rpm = [];
|
|
18
18
|
let currentRpmMinute = 0;
|
|
19
19
|
|
|
20
|
+
// Почасовое кольцо успеха (24 бина по UTC-часу). Для sparkline в дашборде.
|
|
21
|
+
// Каждый бин: { hour: 'YYYY-MM-DDTHH', ok, total }. Пустые часы (ещё не наступили) — total:0.
|
|
22
|
+
const HOURLY_MAX = 24;
|
|
23
|
+
let hourly = [];
|
|
24
|
+
let hourlyDay = ''; // текущий день контекста (dayKey), чтобы не путать часы между днями
|
|
25
|
+
|
|
26
|
+
function dayKey() {
|
|
27
|
+
return new Date().toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hourKey() {
|
|
31
|
+
return new Date().toISOString().slice(0, 13); // 'YYYY-MM-DDTHH'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ensureHourly() {
|
|
35
|
+
const dk = dayKey();
|
|
36
|
+
if (hourlyDay !== dk || hourly.length === 0) {
|
|
37
|
+
hourlyDay = dk;
|
|
38
|
+
hourly = [];
|
|
39
|
+
// Заполняем прошедшие часы этого UTC-дня нулями + текущий.
|
|
40
|
+
const nowHour = new Date().getUTCHours();
|
|
41
|
+
for (let h = 0; h <= nowHour; h++) {
|
|
42
|
+
hourly.push({ hour: dk + 'T' + String(h).padStart(2, '0'), ok: 0, total: 0 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recordHourly(success) {
|
|
48
|
+
ensureHourly();
|
|
49
|
+
const hk = hourKey();
|
|
50
|
+
const last = hourly[hourly.length - 1];
|
|
51
|
+
if (last && last.hour !== hk) {
|
|
52
|
+
// Новый час — добавляем бин, держим максимум HOURLY_MAX.
|
|
53
|
+
hourly.push({ hour: hk, ok: 0, total: 0 });
|
|
54
|
+
if (hourly.length > HOURLY_MAX) hourly.shift();
|
|
55
|
+
}
|
|
56
|
+
const bin = hourly[hourly.length - 1];
|
|
57
|
+
if (bin) { bin.total++; if (success) bin.ok++; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getHourly() {
|
|
61
|
+
ensureHourly();
|
|
62
|
+
return hourly;
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
function loadState() {
|
|
21
66
|
try {
|
|
22
67
|
const data = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
|
@@ -129,6 +174,7 @@ function classifyErrorMs(statusCode, opts) {
|
|
|
129
174
|
|
|
130
175
|
function recordRequest(providerKey, success, error = null) {
|
|
131
176
|
recordRpm();
|
|
177
|
+
recordHourly(success);
|
|
132
178
|
stats.totalRequests++;
|
|
133
179
|
const today = new Date().toISOString().slice(0, 10);
|
|
134
180
|
// Error counter is all-time by default; reset it once per UTC day (like reliability).
|
|
@@ -233,6 +279,7 @@ function warmBanditPriors(enabledKeys, bucketKeys) {
|
|
|
233
279
|
|
|
234
280
|
// Auto-save every 30 seconds
|
|
235
281
|
const _saveTimer = setInterval(saveState, 30000);
|
|
282
|
+
if (_saveTimer.unref) _saveTimer.unref();
|
|
236
283
|
|
|
237
284
|
function _stopTimers() { clearInterval(_saveTimer); }
|
|
238
285
|
|
|
@@ -242,6 +289,7 @@ module.exports = {
|
|
|
242
289
|
getHealth, getStats, getCircuitBreakers,
|
|
243
290
|
recordRecent, recordRpm, getRecent, getRpm,
|
|
244
291
|
getDailyUsage, getReliability,
|
|
292
|
+
getHourly,
|
|
245
293
|
recordSelection, getLastSelection,
|
|
246
294
|
getBandit, recordBandit, warmBanditPriors,
|
|
247
295
|
getContextStats: () => contextStats,
|
package/lib/methodology.js
CHANGED
|
@@ -26,8 +26,28 @@ const METHODOLOGIES = {
|
|
|
26
26
|
'Методолог (chat): отвечай по существу и кратко, по-русски.',
|
|
27
27
|
'Если просят код или план — переключись на соответствующий стиль.',
|
|
28
28
|
].join('\n'),
|
|
29
|
+
design: [
|
|
30
|
+
'Методолог (design): перед кодом — определи смелую эстетическую идею. Не делай «generic AI-шаблон».',
|
|
31
|
+
'Выбери тон: минимализм / максимализм / ретро-футуризм / лакшери / брутализм / мягкий пастельный и т.д. — и держись его.',
|
|
32
|
+
'Типографика: подбери характерные шрифты (не Arial/Inter/Roboto), связка display + body.',
|
|
33
|
+
'Цвет: единая палитра через CSS-переменные, доминанта + резкий акцент.',
|
|
34
|
+
'Движение: одна хорошо срежиссированная загрузка (staggered reveals) и микро-взаимодействия; приоритет CSS-only.',
|
|
35
|
+
'Композиция и фон: асимметрия, наложение, негативное пространство, текстуры/градиенты/шум — вместо плоских заливок.',
|
|
36
|
+
'Реализуй рабочий код (HTML/CSS/JS, React, Vue). Адаптив mobile-first, доступность (контраст, aria).',
|
|
37
|
+
'Каждый дизайн — разный. Не повторяйся между проектами и не ходи в Space Grotesk по умолчанию.',
|
|
38
|
+
].join('\n'),
|
|
29
39
|
};
|
|
30
40
|
|
|
41
|
+
// Общий блок «поведение»: прививает модели сверхспособности — не сдаваться, а
|
|
42
|
+
// искать обходной путь, как DeepSeek. Применяется ко всем категориям независимо
|
|
43
|
+
// от переопределения промптов (config.methodology.prompts).
|
|
44
|
+
const BEHAVIOR = [
|
|
45
|
+
'Поведение (не сдавайся): если не находишь нужный инструмент/файл/скилл там, где ожидал —',
|
|
46
|
+
'не говори «не могу». Ищи обходной путь: читай файлы с диска напрямую (cat), проверь node_modules,',
|
|
47
|
+
'посмотри доступные инструменты, попробуй альтернативу. Сначала попробуй — потом решай.',
|
|
48
|
+
'Если всё же не выходит — одной строкой: что искал, где, и что предлагаешь сделать.',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
31
51
|
function getMethodology(category, options) {
|
|
32
52
|
const opts = options || {};
|
|
33
53
|
const prompts = (opts.prompts && typeof opts.prompts === 'object') ? opts.prompts : {};
|
|
@@ -63,8 +83,9 @@ function injectMethodology(messages, category, options) {
|
|
|
63
83
|
}
|
|
64
84
|
|
|
65
85
|
const copy = messages.slice();
|
|
66
|
-
|
|
86
|
+
const full = text + '\n\n' + BEHAVIOR;
|
|
87
|
+
copy.splice(insertAt, 0, { role: 'system', content: full });
|
|
67
88
|
return copy;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
module.exports = { getMethodology, injectMethodology, enabledByDefault, METHODOLOGIES };
|
|
91
|
+
module.exports = { getMethodology, injectMethodology, enabledByDefault, METHODOLOGIES, BEHAVIOR };
|
package/lib/modelscan.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (только :free), groq, cerebras, gemini (free tier), nim (малый лимит, но
|
|
10
10
|
// стабильно бесплатный), huggingface. Каталог расширяется вручную через
|
|
11
11
|
// config.json/providers.json.
|
|
12
|
-
const SOURCES = ['openrouter', 'huggingface', 'groq', 'gemini', 'cerebras', 'nim'];
|
|
12
|
+
const SOURCES = ['openrouter', 'huggingface', 'groq', 'gemini', 'cerebras', 'nim', 'sambanova', 'siliconflow', 'deepinfra', 'hyperbolic', 'cohere'];
|
|
13
13
|
|
|
14
14
|
// Endpoints списков моделей и chat-completions по источникам.
|
|
15
15
|
const SOURCE_META = {
|
|
@@ -21,6 +21,11 @@ const SOURCE_META = {
|
|
|
21
21
|
cerebras: { list: 'https://api.cerebras.ai/v1/models', chat: 'https://api.cerebras.ai/v1/chat/completions', envVar: 'PROVIDER_CEREBRAS_APIKEY', dailyLimit: 1000 },
|
|
22
22
|
deepseek: { list: 'https://api.deepseek.com/models', chat: 'https://api.deepseek.com/chat/completions', envVar: 'PROVIDER_DEEPSEEK_APIKEY', dailyLimit: 1000 },
|
|
23
23
|
nim: { list: 'https://integrate.api.nvidia.com/v1/models', chat: 'https://integrate.api.nvidia.com/v1/chat/completions', envVar: 'PROVIDER_NIM_APIKEY', dailyLimit: 40 },
|
|
24
|
+
sambanova: { list: 'https://api.sambanova.ai/v1/models', chat: 'https://api.sambanova.ai/v1/chat/completions', envVar: 'PROVIDER_SAMBANOVA_APIKEY', dailyLimit: 100000 },
|
|
25
|
+
siliconflow: { list: 'https://api.siliconflow.com/v1/models', chat: 'https://api.siliconflow.com/v1/chat/completions', envVar: 'PROVIDER_SILICONFLOW_APIKEY', dailyLimit: 100000 },
|
|
26
|
+
deepinfra: { list: 'https://api.deepinfra.com/v1/openai/models', chat: 'https://api.deepinfra.com/v1/openai/chat/completions', envVar: 'PROVIDER_DEEPINFRA_APIKEY', dailyLimit: 100000 },
|
|
27
|
+
hyperbolic: { list: 'https://api.hyperbolic.xyz/v1/models', chat: 'https://api.hyperbolic.xyz/v1/chat/completions', envVar: 'PROVIDER_HYPERBOLIC_APIKEY', dailyLimit: 100000 },
|
|
28
|
+
cohere: { list: 'https://api.cohere.com/compatibility/v1/models', chat: 'https://api.cohere.com/compatibility/v1/chat/completions', envVar: 'PROVIDER_COHERE_APIKEY', dailyLimit: 100000 },
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
// Мусор: эмбеддинги, реранки, аудио/речь/музыка, guardrail, картинки/видео,
|
|
@@ -101,7 +106,7 @@ const ADAPTERS = {
|
|
|
101
106
|
};
|
|
102
107
|
|
|
103
108
|
// Нативные OpenAI-совместимые списки: один паттерн на все источники.
|
|
104
|
-
for (const name of ['groq', 'mistral', 'gemini', 'cerebras', 'deepseek', 'nim']) {
|
|
109
|
+
for (const name of ['groq', 'mistral', 'gemini', 'cerebras', 'deepseek', 'nim', 'sambanova', 'siliconflow', 'deepinfra', 'hyperbolic', 'cohere']) {
|
|
105
110
|
ADAPTERS[name] = async (ctx) => {
|
|
106
111
|
const meta = SOURCE_META[name];
|
|
107
112
|
const key = ctx.keys[name] || process.env[meta.envVar] || '';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// lib/onboarding.js — готовые сниппеты подключения и профили моделей.
|
|
2
|
+
// Чистые функции: не трогают диск, возвращают текст конфига. Для doctor/init.
|
|
3
|
+
function snippetCursor(baseUrl, apiKey) {
|
|
4
|
+
return [
|
|
5
|
+
'// Cursor → Settings → Models',
|
|
6
|
+
'// Включи провайдер «OpenAI-compatible» и впиши:',
|
|
7
|
+
'Model: openai/gpt-oss-120b (или любая из каталога)',
|
|
8
|
+
'Base URL: ' + baseUrl + '/v1',
|
|
9
|
+
'API Key: ' + apiKey,
|
|
10
|
+
'Add model: столько бесплатных моделей, сколько захочешь.',
|
|
11
|
+
'',
|
|
12
|
+
'Совет: открыть дашборд → ' + baseUrl + ' (пароль = API Key).',
|
|
13
|
+
].join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function snippetOpencode(baseUrl, apiKey) {
|
|
17
|
+
return [
|
|
18
|
+
'// opencode — добавь провайдер в opencode.json',
|
|
19
|
+
'{',
|
|
20
|
+
' "$schema": "https://opencode.ai/config.json",',
|
|
21
|
+
' "provider": {',
|
|
22
|
+
' "freegate": {',
|
|
23
|
+
' "npm": "@ai-sdk/openai-compatible",',
|
|
24
|
+
' "name": "Freegate",',
|
|
25
|
+
' "options": {',
|
|
26
|
+
' "baseURL": "' + baseUrl + '/v1",',
|
|
27
|
+
' "apiKey": "' + apiKey + '"',
|
|
28
|
+
' }',
|
|
29
|
+
' }',
|
|
30
|
+
' },',
|
|
31
|
+
' "model": "freegate/openai/gpt-oss-120b"',
|
|
32
|
+
'}',
|
|
33
|
+
'',
|
|
34
|
+
'Затем: npx opencode (выбери модель freegate).',
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function snippetCline(baseUrl, apiKey) {
|
|
39
|
+
return [
|
|
40
|
+
'// Cline (VS Code) → Settings → API Provider: OpenAI Compatible',
|
|
41
|
+
'Base URL: ' + baseUrl + '/v1',
|
|
42
|
+
'API Key: ' + apiKey,
|
|
43
|
+
'Model: openai/gpt-oss-120b',
|
|
44
|
+
'',
|
|
45
|
+
'Убедись, что в Cline включён "OpenAI Compatible" провайдер.',
|
|
46
|
+
].join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Профили: пресеты под разные роли (каких моделей больше в роутинге).
|
|
50
|
+
function profiles() {
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
id: 'chat',
|
|
54
|
+
name: 'Чат-ассистент',
|
|
55
|
+
hint: 'Общие вопросы, переписка. Категория chat.',
|
|
56
|
+
prefer: ['general', 'chat'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'coder',
|
|
60
|
+
name: 'Кодер',
|
|
61
|
+
hint: 'Код, рефакторинг, тесты. Категория coding.',
|
|
62
|
+
prefer: ['coding', 'reasoning'],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'designer',
|
|
66
|
+
name: 'Дизайнер',
|
|
67
|
+
hint: 'UI/UX, фронтенд. Категория design.',
|
|
68
|
+
prefer: ['coding', 'design'],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'researcher',
|
|
72
|
+
name: 'Исследователь',
|
|
73
|
+
hint: 'Поиск фактов, веб-поиск. Категория search.',
|
|
74
|
+
prefer: ['search', 'reasoning'],
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { snippetCursor, snippetOpencode, snippetCline, profiles };
|
package/lib/setup.js
CHANGED
|
@@ -193,6 +193,135 @@ const KEY_GROUPS = {
|
|
|
193
193
|
'Paste it into the field below',
|
|
194
194
|
],
|
|
195
195
|
},
|
|
196
|
+
PROVIDER_SAMBANOVA_APIKEY: {
|
|
197
|
+
name: 'SambaNova',
|
|
198
|
+
name_en: 'SambaNova',
|
|
199
|
+
url: 'https://cloud.sambanova.ai/',
|
|
200
|
+
description: 'MiniMax-M2.7, DeepSeek-V3.2, gpt-oss-120b. Очень быстрый инференс, бесплатно.',
|
|
201
|
+
description_en: 'MiniMax-M2.7, DeepSeek-V3.2, gpt-oss-120b. Very fast inference, free.',
|
|
202
|
+
steps: [
|
|
203
|
+
'Зайди на cloud.sambanova.ai и зарегистрируйся',
|
|
204
|
+
'Перейди в Settings → API Keys',
|
|
205
|
+
'Создай ключ и скопируй',
|
|
206
|
+
'Вставь в поле ниже',
|
|
207
|
+
],
|
|
208
|
+
steps_en: [
|
|
209
|
+
'Go to cloud.sambanova.ai and register',
|
|
210
|
+
'Go to Settings → API Keys',
|
|
211
|
+
'Create a key and copy it',
|
|
212
|
+
'Paste it into the field below',
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
PROVIDER_SILICONFLOW_APIKEY: {
|
|
216
|
+
name: 'SiliconFlow',
|
|
217
|
+
name_en: 'SiliconFlow',
|
|
218
|
+
url: 'https://cloud.siliconflow.com/account/ak',
|
|
219
|
+
description: 'DeepSeek-R1, Qwen3-Coder, Kimi-K2.5, GLM-4.7. Крупный бесплатный каталог.',
|
|
220
|
+
description_en: 'DeepSeek-R1, Qwen3-Coder, Kimi-K2.5, GLM-4.7. Large free catalog.',
|
|
221
|
+
steps: [
|
|
222
|
+
'Зайди на cloud.siliconflow.com и зарегистрируйся',
|
|
223
|
+
'Перейди в API Keys',
|
|
224
|
+
'Создай ключ (sk-...)',
|
|
225
|
+
'Вставь в поле ниже',
|
|
226
|
+
],
|
|
227
|
+
steps_en: [
|
|
228
|
+
'Go to cloud.siliconflow.com and register',
|
|
229
|
+
'Go to API Keys',
|
|
230
|
+
'Create a key (sk-...)',
|
|
231
|
+
'Paste it into the field below',
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
PROVIDER_DEEPINFRA_APIKEY: {
|
|
235
|
+
name: 'DeepInfra',
|
|
236
|
+
name_en: 'DeepInfra',
|
|
237
|
+
url: 'https://deepinfra.com/dash/api_keys',
|
|
238
|
+
description: 'gpt-oss-120b/20b, Nemotron-Super-120B, Llama-4. Бесплатный инференс.',
|
|
239
|
+
description_en: 'gpt-oss-120b/20b, Nemotron-Super-120B, Llama-4. Free inference.',
|
|
240
|
+
steps: [
|
|
241
|
+
'Зайди на deepinfra.com и зарегистрируйся',
|
|
242
|
+
'Перейди в Dashboard → API Keys',
|
|
243
|
+
'Создай ключ и скопируй',
|
|
244
|
+
'Вставь в поле ниже',
|
|
245
|
+
],
|
|
246
|
+
steps_en: [
|
|
247
|
+
'Go to deepinfra.com and register',
|
|
248
|
+
'Go to Dashboard → API Keys',
|
|
249
|
+
'Create a key and copy it',
|
|
250
|
+
'Paste it into the field below',
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
PROVIDER_HYPERBOLIC_APIKEY: {
|
|
254
|
+
name: 'Hyperbolic',
|
|
255
|
+
name_en: 'Hyperbolic',
|
|
256
|
+
url: 'https://app.hyperbolic.xyz/',
|
|
257
|
+
description: 'DeepSeek-V3, Qwen-Coder, Llama-3.3-70B. Бесплатный старт с кредитом.',
|
|
258
|
+
description_en: 'DeepSeek-V3, Qwen-Coder, Llama-3.3-70B. Free start with credit.',
|
|
259
|
+
steps: [
|
|
260
|
+
'Зайди на app.hyperbolic.xyz и зарегистрируйся',
|
|
261
|
+
'Перейди в Settings → API Keys',
|
|
262
|
+
'Создай ключ и скопируй',
|
|
263
|
+
'Вставь в поле ниже',
|
|
264
|
+
],
|
|
265
|
+
steps_en: [
|
|
266
|
+
'Go to app.hyperbolic.xyz and register',
|
|
267
|
+
'Go to Settings → API Keys',
|
|
268
|
+
'Create a key and copy it',
|
|
269
|
+
'Paste it into the field below',
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
PROVIDER_COHERE_APIKEY: {
|
|
273
|
+
name: 'Cohere',
|
|
274
|
+
name_en: 'Cohere',
|
|
275
|
+
url: 'https://dashboard.cohere.com/',
|
|
276
|
+
description: 'Command A, Command R+. Бесплатный Trial-ключ.',
|
|
277
|
+
description_en: 'Command A, Command R+. Free trial key.',
|
|
278
|
+
steps: [
|
|
279
|
+
'Зайди на dashboard.cohere.com и зарегистрируйся',
|
|
280
|
+
'Перейди в API Keys',
|
|
281
|
+
'Создай Trial-ключ',
|
|
282
|
+
'Вставь в поле ниже',
|
|
283
|
+
],
|
|
284
|
+
steps_en: [
|
|
285
|
+
'Go to dashboard.cohere.com and register',
|
|
286
|
+
'Go to API Keys',
|
|
287
|
+
'Create a trial key',
|
|
288
|
+
'Paste it into the field below',
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
PROVIDER_LLM7_APIKEY: {
|
|
292
|
+
name: 'LLM7',
|
|
293
|
+
name_en: 'LLM7',
|
|
294
|
+
url: 'https://llm7.io/',
|
|
295
|
+
description: 'gpt-4o-mini, DeepSeek-R1, Qwen2.5-Coder. Бесплатный ключ при регистрации.',
|
|
296
|
+
description_en: 'gpt-4o-mini, DeepSeek-R1, Qwen2.5-Coder. Free key on signup.',
|
|
297
|
+
steps: [
|
|
298
|
+
'Зайди на llm7.io и зарегистрируйся',
|
|
299
|
+
'Получи бесплатный API-ключ',
|
|
300
|
+
'Вставь в поле ниже',
|
|
301
|
+
],
|
|
302
|
+
steps_en: [
|
|
303
|
+
'Go to llm7.io and register',
|
|
304
|
+
'Get the free API key',
|
|
305
|
+
'Paste it into the field below',
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
PROVIDER_NARA_APIKEY: {
|
|
309
|
+
name: 'Nara (bynara)',
|
|
310
|
+
name_en: 'Nara (bynara)',
|
|
311
|
+
url: 'https://nara.ai/',
|
|
312
|
+
description: 'Mistral Large/Medium, Tencent Hy3. Бесплатный ключ через их Telegram-канал.',
|
|
313
|
+
description_en: 'Mistral Large/Medium, Tencent Hy3. Free key via their Telegram channel.',
|
|
314
|
+
steps: [
|
|
315
|
+
'Зайди на nara.ai / вступи в их Telegram-канал',
|
|
316
|
+
'Получи бесплатный ключ',
|
|
317
|
+
'Вставь в поле ниже',
|
|
318
|
+
],
|
|
319
|
+
steps_en: [
|
|
320
|
+
'Go to nara.ai / join their Telegram channel',
|
|
321
|
+
'Get the free key',
|
|
322
|
+
'Paste it into the field below',
|
|
323
|
+
],
|
|
324
|
+
},
|
|
196
325
|
};
|
|
197
326
|
|
|
198
327
|
// --- .env file path resolution (mirrors lib/providers.js logic) ---
|
|
@@ -282,6 +411,13 @@ async function validateKey(envVar, apiKey) {
|
|
|
282
411
|
PROVIDER_CEREBRAS_APIKEY: 'https://api.cerebras.ai/v1/models',
|
|
283
412
|
PROVIDER_DEEPSEEK_APIKEY: 'https://api.deepseek.com/models',
|
|
284
413
|
PROVIDER_HF_APIKEY: 'https://router.huggingface.co/v1/models',
|
|
414
|
+
PROVIDER_SAMBANOVA_APIKEY: 'https://api.sambanova.ai/v1/models',
|
|
415
|
+
PROVIDER_SILICONFLOW_APIKEY: 'https://api.siliconflow.com/v1/models',
|
|
416
|
+
PROVIDER_DEEPINFRA_APIKEY: 'https://api.deepinfra.com/v1/openai/models',
|
|
417
|
+
PROVIDER_HYPERBOLIC_APIKEY: 'https://api.hyperbolic.xyz/v1/models',
|
|
418
|
+
PROVIDER_COHERE_APIKEY: 'https://api.cohere.com/compatibility/v1/models',
|
|
419
|
+
PROVIDER_LLM7_APIKEY: 'https://api.llm7.io/v1/models',
|
|
420
|
+
PROVIDER_NARA_APIKEY: 'https://router.bynara.id/v1/models',
|
|
285
421
|
};
|
|
286
422
|
|
|
287
423
|
const url = testEndpoints[envVar];
|
package/lib/strategy.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// lib/strategy.js — дополнительные стратегии роутинга (по мотивам OmniRoute).
|
|
2
|
+
// Базовый выбор в Freegate — weighted (скор/латентность + категория + лимиты).
|
|
3
|
+
// Здесь — опциональные модификаторы, которые НЕ заменяют основной скоринг, а
|
|
4
|
+
// подмешивают к нему равномерность использования (round-robin / least-used),
|
|
5
|
+
// чтобы не «прилипать» к одному провайдеру и ровнее расходовать free-лимиты.
|
|
6
|
+
//
|
|
7
|
+
// Управление: config.routing.strategy = 'weighted' | 'weighted-roundrobin' | 'weighted-least'.
|
|
8
|
+
// Всегда остаётся fallback на weighted (стратегия лишь меняет вес, не исключает).
|
|
9
|
+
|
|
10
|
+
const STRATEGIES = ['weighted', 'weighted-roundrobin', 'weighted-least'];
|
|
11
|
+
|
|
12
|
+
const ROUND_ROBIN_BOOST = 1.6; // во сколько раз поднимаем «следующего по кругу»
|
|
13
|
+
const LEAST_USED_BOOST = 1.5; // поднимаем провайдеров с наименьшим использованием
|
|
14
|
+
|
|
15
|
+
// Кольцевой счётчик round-robin по совокупности провайдеров.
|
|
16
|
+
let rrCursor = 0;
|
|
17
|
+
|
|
18
|
+
function normalizeStrategy(value) {
|
|
19
|
+
const s = String(value || 'weighted').toLowerCase();
|
|
20
|
+
return STRATEGIES.includes(s) ? s : 'weighted';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Вернуть функцию-модификатор веса для конкретного провайдера.
|
|
24
|
+
// ctx: { key, provider, usedTodayList } — usedTodayList: {key: count} за день.
|
|
25
|
+
function makeWeightModifier(strategy, ctx = {}) {
|
|
26
|
+
const s = normalizeStrategy(strategy);
|
|
27
|
+
if (s === 'weighted') {
|
|
28
|
+
return () => 1; // ничего не меняем
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const usedTodayList = ctx.usedTodayList || {};
|
|
32
|
+
|
|
33
|
+
if (s === 'weighted-roundrobin') {
|
|
34
|
+
// Поднимаем провайдера, который «следующий по кругу». Простейшая реализация:
|
|
35
|
+
// модуль хранит курсор; при выборе провайдера cursor == его позиции — буст.
|
|
36
|
+
const keys = ctx.keys || [];
|
|
37
|
+
const nextKey = keys.length > 0 ? keys[rrCursor % keys.length] : null;
|
|
38
|
+
rrCursor++;
|
|
39
|
+
return (key) => (key === nextKey ? ROUND_ROBIN_BOOST : 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (s === 'weighted-least') {
|
|
43
|
+
// Поднимаем тех, кто использован меньше среднего.
|
|
44
|
+
const counts = Object.values(usedTodayList);
|
|
45
|
+
const avg = counts.length > 0 ? counts.reduce((a, b) => a + b, 0) / counts.length : 0;
|
|
46
|
+
return (key) => {
|
|
47
|
+
const used = usedTodayList[key] || 0;
|
|
48
|
+
return used < avg ? LEAST_USED_BOOST : 1;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return () => 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resetRoundRobin() { rrCursor = 0; }
|
|
56
|
+
|
|
57
|
+
module.exports = { STRATEGIES, normalizeStrategy, makeWeightModifier, resetRoundRobin, ROUND_ROBIN_BOOST, LEAST_USED_BOOST };
|
package/lib/taskclassify.js
CHANGED
|
@@ -21,7 +21,33 @@ const REASONING_HINTS = [
|
|
|
21
21
|
const SEARCH_HINTS = [
|
|
22
22
|
'что такое', 'найди', 'где находится', 'как называется', 'что означает',
|
|
23
23
|
'объясни термин', 'find', 'search', 'lookup', 'что за', 'кто такой',
|
|
24
|
+
'как работает', 'как настроить', 'как установить', 'как использовать',
|
|
25
|
+
'расскажи про', 'расскажи о', 'про что', 'где скачать', 'где найти',
|
|
26
|
+
'найди информацию', 'поиск', 'что значит', 'в чем разница', 'как сделать',
|
|
24
27
|
];
|
|
28
|
+
// Слова, начинающие СОБСТВЕННО поиск/объяснение термина — перехватывают раньше
|
|
29
|
+
// reasoning, чтобы «что означает X» не уходило в «рассуждай пошагово».
|
|
30
|
+
const SEARCH_PREFIX_RE = /^(что (такое|значит|означает)|расскажи|объясни|как|где|кто|найди)\b/i;
|
|
31
|
+
|
|
32
|
+
// Frontend-дизайн: интерфейсы, вёрстка, UI/UX, макеты, лендинги, компоненты.
|
|
33
|
+
// Ловим ДО coding, т.к. «сверстай компонент» — это дизайн-задача, а не просто код.
|
|
34
|
+
const DESIGN_HINTS = [
|
|
35
|
+
'лендинг', 'landing', 'интерфейс', 'ui', 'ux', 'верстк', 'вёрстк', 'макет',
|
|
36
|
+
'фронтенд', 'frontend', 'front-end', 'hero', 'кнопку', 'кнопки', 'форма',
|
|
37
|
+
'форму', 'навбар', 'sidebar', 'карточк', 'галере', 'модал', 'адаптив',
|
|
38
|
+
'дизайн', 'шрифт', 'типограф', 'анимац', 'палитр', 'тёмн тем', 'светл тем',
|
|
39
|
+
'стили', 'tailwind', 'css', 'scss', 'jsx', 'компонент', 'страниц', 'элемент',
|
|
40
|
+
'секци', 'блок', 'визуал', 'красив', 'глянц', 'presentation', 'постер',
|
|
41
|
+
];
|
|
42
|
+
// Явный фронтенд-сигнал: просят UI-штуку, но это не про логику/бэкенд.
|
|
43
|
+
const DESIGN_UI_RE = /(сверста|нарису|создай|сделай|оформи|дизайн)\b.*(интерфейс|лендинг|верстк|вёрстк|страниц|компонент|ui|ux|form|макет|сайт|иконк|кнопк)/i;
|
|
44
|
+
const DESIGN_TECH_RE = /\b(react|vue|svelte|tailwind|css|scss|jsx|html|figma)\b/i;
|
|
45
|
+
// Вопрос/запрос о дизайне (не задача-создание): «что ты знаешь про дизайн»,
|
|
46
|
+
// «расскажи про минимакс дизайн» — это поиск фактов, а не создание UI.
|
|
47
|
+
// Иначе слово «дизайн» уводит запрос в методолог-дизайнер, и модель отвечает
|
|
48
|
+
// про стиль/минимализм вместо запрошенного предмета (MiniMax-нейросеть и т.п.).
|
|
49
|
+
// ВАЖНО: без \b после кириллицы — \b в JS не видит кириллические слова.
|
|
50
|
+
const DESIGN_QUERY_RE = /^(что (ты )?(знаешь|такое|такая|такой)|расскажи|опиши|найди инфу|что значит|как называется|кто такой).*(дизайн|ui|ux|верстк|вёрстк|лендинг)/i;
|
|
25
51
|
|
|
26
52
|
function lastUserText(messages) {
|
|
27
53
|
if (!Array.isArray(messages)) return '';
|
|
@@ -39,6 +65,19 @@ function classify(messages) {
|
|
|
39
65
|
const text = lastUserText(messages).trim();
|
|
40
66
|
if (!text) return 'chat';
|
|
41
67
|
const lower = text.toLowerCase();
|
|
68
|
+
// Вопрос о дизайне («что ты знаешь про дизайн») — это ПОИСК, а не задача
|
|
69
|
+
// создания UI. Ловим раньше design, иначе слово «дизайн» уводит модель в
|
|
70
|
+
// методолог-дизайнер и она отвечает про стиль вместо запрошенного предмета.
|
|
71
|
+
if (DESIGN_QUERY_RE.test(text)) return 'search';
|
|
72
|
+
// Frontend-дизайн: показываем UI-запросы как design, а не coding — агент
|
|
73
|
+
// отвечает как дизайнер (продуманный UI/UX), а не «просто код».
|
|
74
|
+
if (DESIGN_UI_RE.test(text)) return 'design';
|
|
75
|
+
if (DESIGN_TECH_RE.test(text) && /(свёрст|верстк|вёрстк|интерфейс|страниц|компонент|лендинг|ui|ux|макет)/i.test(text)) return 'design';
|
|
76
|
+
// Явные UI-сущности без глагола создания — тоже дизайн-работа («карточка
|
|
77
|
+
// товара», «навбар», «модалка»).
|
|
78
|
+
if (/(сверста|нарису|сделай|создай|оформи|добавь|допили|поправь)\b/i.test(text) &&
|
|
79
|
+
/(верстк|вёрстк|интерфейс|лендинг|карточк|кнопк|форму|навбар|модал|адаптив|компонент|блок|секци|страниц|бокс|панел)/i.test(text)) return 'design';
|
|
80
|
+
if (DESIGN_HINTS.some(h => h !== 'дизайн' && lower.includes(h))) return 'design';
|
|
42
81
|
|
|
43
82
|
// Код — сильный сигнал: читаем/пишем код, чиним, рефакторим.
|
|
44
83
|
if (looksLikeCode(messages)) return 'coding';
|
|
@@ -46,13 +85,15 @@ function classify(messages) {
|
|
|
46
85
|
if (/[a-zA-Z_$][\w$]*\s*\(/.test(text) && /=>|\{|;|return|\.\w+\(/.test(text)) return 'coding';
|
|
47
86
|
if (CODING_HINTS.some(h => lower.includes(h))) return 'coding';
|
|
48
87
|
|
|
88
|
+
// Поиск/объяснение термина — сильный сигнал, ловим ДО reasoning (иначе
|
|
89
|
+
// «что означает аннотация» уходит в «рассуждай пошагово», а это краткий факт).
|
|
90
|
+
if (SEARCH_PREFIX_RE.test(text)) return 'search';
|
|
91
|
+
if (SEARCH_HINTS.some(h => lower.includes(h))) return 'search';
|
|
92
|
+
|
|
49
93
|
// Рассуждения — числа, подсчёт, объяснение причин.
|
|
50
94
|
if (/\d/.test(text) && /[+\-*/%]|сколько|посчитай|рассчитай/.test(lower)) return 'reasoning';
|
|
51
95
|
if (REASONING_HINTS.some(h => lower.includes(h))) return 'reasoning';
|
|
52
96
|
|
|
53
|
-
// Поиск — спрашивают, что это такое, где найти.
|
|
54
|
-
if (SEARCH_HINTS.some(h => lower.includes(h))) return 'search';
|
|
55
|
-
|
|
56
97
|
return 'chat';
|
|
57
98
|
}
|
|
58
99
|
|