neuroalpha 0.0.0-stage → 0.2.0

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.
Files changed (46) hide show
  1. package/README.md +164 -3
  2. package/bin/cli.js +69 -0
  3. package/config/strategy.json +33 -0
  4. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +156 -0
  5. package/package.json +43 -6
  6. package/public/app.js +2410 -0
  7. package/public/index.html +337 -0
  8. package/public/landing.html +870 -0
  9. package/public/legal.html +90 -0
  10. package/public/styles.css +343 -0
  11. package/server/api/routes.js +333 -0
  12. package/server/auth/index.js +340 -0
  13. package/server/billing/yookassa.js +132 -0
  14. package/server/brokers/index.js +645 -0
  15. package/server/config.js +44 -0
  16. package/server/data/moex.js +165 -0
  17. package/server/db.js +420 -0
  18. package/server/hub.js +14 -0
  19. package/server/index.js +92 -0
  20. package/server/llm/client.js +187 -0
  21. package/server/llm/providers.js +355 -0
  22. package/server/model/dataset.js +352 -0
  23. package/server/model/forecast.js +121 -0
  24. package/server/model/gpu-worker.html +11 -0
  25. package/server/model/gpu-worker.js +60 -0
  26. package/server/model/linalg.js +142 -0
  27. package/server/model/live.js +486 -0
  28. package/server/model/metrics.js +134 -0
  29. package/server/model/models.js +459 -0
  30. package/server/model/profiles.js +166 -0
  31. package/server/model/research.js +880 -0
  32. package/server/model/tf-node.js +26 -0
  33. package/server/model/transformer-core.js +330 -0
  34. package/server/model/transformer-gpu.js +221 -0
  35. package/server/model/transformer-pool.js +200 -0
  36. package/server/model/transformer-worker.js +28 -0
  37. package/server/model/transformer.js +57 -0
  38. package/server/model/vocab.js +28 -0
  39. package/server/news/index.js +309 -0
  40. package/server/notify/index.js +168 -0
  41. package/server/params-meta.js +63 -0
  42. package/server/settings.js +79 -0
  43. package/server/strategist/analytics.js +150 -0
  44. package/server/strategist/index.js +484 -0
  45. package/server/strategist/tools.js +302 -0
  46. package/server/util.js +9 -0
@@ -0,0 +1,92 @@
1
+ import http from 'node:http';
2
+ import os from 'node:os';
3
+ import { spawn } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import express from 'express';
6
+ import { WebSocketServer } from 'ws';
7
+ import { ROOT, PORT } from './config.js';
8
+ import { activeModelInfo } from './llm/client.js';
9
+ import { markStaleRuns, removeLegacyData, removeOrphanEvents } from './db.js';
10
+ import { createRouter } from './api/routes.js';
11
+ import { authMiddleware, activateLocalLicense } from './auth/index.js';
12
+ import { live } from './model/live.js';
13
+ import { runningResearch, runningStability } from './model/research.js';
14
+ import { startNews } from './news/index.js';
15
+ import { startNotifyPolling } from './notify/index.js';
16
+ import { startBrokerScheduler } from './brokers/index.js';
17
+ import { chatState } from './strategist/index.js';
18
+ import { hub } from './hub.js';
19
+
20
+ markStaleRuns();
21
+ removeLegacyData();
22
+ removeOrphanEvents();
23
+
24
+ const app = express();
25
+ app.use(express.json({ limit: '1mb' }));
26
+ app.use(authMiddleware);
27
+ const server = http.createServer(app);
28
+ const wss = new WebSocketServer({ server, path: '/ws' });
29
+
30
+ hub.setSender((msg) => {
31
+ const data = JSON.stringify(msg);
32
+ for (const c of wss.clients) if (c.readyState === 1) c.send(data);
33
+ });
34
+
35
+ wss.on('connection', (ws) => {
36
+ ws.send(JSON.stringify({ type: 'hello', live: live.status(), chat: chatState(), running: runningResearch(), stabilityRunning: runningStability(), llm: activeModelInfo() }));
37
+ });
38
+
39
+ live.resume();
40
+ startNews();
41
+ startNotifyPolling();
42
+ startBrokerScheduler();
43
+
44
+ if (process.env.NEUROALPHA_API_KEY) {
45
+ activateLocalLicense(process.env.NEUROALPHA_API_KEY, process.env.CLOUD_URL || '')
46
+ .then((lic) => console.log(`✓ Активирован API-ключ: тариф «${lic.planName}» (${lic.email})`))
47
+ .catch((e) => console.warn(`⚠ Проверка API-ключа: ${e.message}`));
48
+ }
49
+
50
+ app.get('/landing', (req, res) => res.sendFile(path.join(ROOT, 'public', 'landing.html')));
51
+ app.get('/legal', (req, res) => res.sendFile(path.join(ROOT, 'public', 'legal.html')));
52
+ app.get('/', (req, res, next) => {
53
+ if (process.env.CLOUD_MODE === '1' && !req.user && req.query.terminal !== '1' && !req.query.run) {
54
+ return res.sendFile(path.join(ROOT, 'public', 'landing.html'));
55
+ }
56
+ next();
57
+ });
58
+ app.use('/api', createRouter());
59
+ app.use('/docs', express.static(path.join(ROOT, 'docs')));
60
+ app.use(express.static(path.join(ROOT, 'public')));
61
+
62
+ function lanUrls() {
63
+ return Object.values(os.networkInterfaces()).flat()
64
+ .filter((a) => a && a.family === 'IPv4' && !a.internal)
65
+ .map((a) => `http://${a.address}:${PORT}`);
66
+ }
67
+
68
+ wss.on('error', () => {});
69
+ server.on('error', (e) => {
70
+ if (e.code === 'EADDRINUSE') console.error(`Порт ${PORT} занят: сервер уже запущен? Клиент: http://localhost:${PORT}`);
71
+ else console.error(e);
72
+ process.exit(1);
73
+ });
74
+
75
+ server.listen(PORT, () => {
76
+ console.log('Консорциум агентов запущен');
77
+ const llm = activeModelInfo();
78
+ console.log(`ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (нет модели или ключа: Настройки → Модели ИИ)'}`);
79
+ for (const url of lanUrls()) console.log(` В локальной сети: ${url}`);
80
+ console.log(`Клиент: http://localhost:${PORT}`);
81
+ if (process.env.OPEN_BROWSER === '1') openBrowser(`http://localhost:${PORT}`);
82
+ });
83
+
84
+ function openBrowser(url) {
85
+ const [cmd, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
86
+ : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
87
+ spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true }).on('error', () => {}).unref();
88
+ }
89
+
90
+ const shutdown = () => { live.suspend().finally(() => server.close(() => process.exit(0))); setTimeout(() => process.exit(0), 2000); };
91
+ process.on('SIGINT', shutdown);
92
+ process.on('SIGTERM', shutdown);
@@ -0,0 +1,187 @@
1
+ import { llmConfig } from '../config.js';
2
+ import { resolveActive, isReady, providerFetch } from './providers.js';
3
+
4
+ export const llmStats = { calls: 0, errors: 0, totalMs: 0, lastError: null, lastAt: null };
5
+
6
+ export function activeModelInfo() {
7
+ const t = resolveActive();
8
+ return {
9
+ enabled: isReady(t),
10
+ provider: t?.provider.name || '',
11
+ providerId: t?.provider.id || '',
12
+ protocol: t?.protocol || '',
13
+ model: t?.model || '',
14
+ label: t?.label || '',
15
+ tools: !!t?.tools,
16
+ };
17
+ }
18
+
19
+ export const isLlmEnabled = () => isReady(resolveActive());
20
+
21
+ // Потоковый запрос к модели. onDelta({ reasoning?, content? }) вызывается по мере генерации.
22
+ // Возвращает { content, reasoning, toolCalls, finishReason }.
23
+ // Таймаут — «нет новых данных idleMs», а не на весь ответ; signal позволяет прервать запрос.
24
+ // target — явно заданная модель (проверка подключения); по умолчанию — выбранная в реестре.
25
+ export async function chatStream(messages, { tools, maxTokens = llmConfig.maxTokens, temperature = 0.2, onDelta = () => {}, signal, idleMs = llmConfig.timeoutMs, target } = {}) {
26
+ const t = target || resolveActive();
27
+ if (!isReady(t)) throw new Error('ИИ не настроен: выберите модель в «Настройки → Модели ИИ»');
28
+ const started = Date.now();
29
+ if (!target) llmStats.calls++;
30
+ try {
31
+ const r = t.protocol === 'anthropic'
32
+ ? await anthropic(t, messages, maxTokens, temperature, onDelta, signal, idleMs)
33
+ : await openaiStream(t, messages, { tools, maxTokens, temperature, onDelta, signal, idleMs });
34
+ if (!target) { llmStats.totalMs += Date.now() - started; llmStats.lastAt = Date.now(); }
35
+ return r;
36
+ } catch (e) {
37
+ if (!target) { llmStats.errors++; llmStats.lastError = e.message; }
38
+ throw e;
39
+ }
40
+ }
41
+
42
+ export async function chat(messages, opts = {}) {
43
+ const r = await chatStream(messages, opts);
44
+ if (!r.content) throw new Error(`Пустой ответ ИИ (finish_reason=${r.finishReason})`);
45
+ return r.content;
46
+ }
47
+
48
+ // GigaChat (формат functions): один вызов функции на сообщение, результат — сообщением с ролью function
49
+ function gigaMessages(messages) {
50
+ const results = new Map(messages.filter((m) => m.role === 'tool').map((m) => [m.tool_call_id, m.content]));
51
+ const out = [];
52
+ for (const m of messages) {
53
+ if (m.role === 'tool') continue;
54
+ if (m.role !== 'assistant' || !m.tool_calls?.length) { out.push({ role: m.role, content: m.content ?? '' }); continue; }
55
+ for (const c of m.tool_calls) {
56
+ let args = {};
57
+ try { args = JSON.parse(c.function.arguments || '{}') || {}; } catch { /* пустые аргументы */ }
58
+ out.push({ role: 'assistant', content: '', function_call: { name: c.function.name, arguments: args } });
59
+ out.push({ role: 'function', name: c.function.name, content: results.get(c.id) ?? '{}' });
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ async function openaiStream(t, messages, { tools, maxTokens, temperature, onDelta, signal, idleMs }) {
66
+ const giga = t.protocol === 'gigachat';
67
+ const body = { model: t.model, messages: giga ? gigaMessages(messages) : messages, max_tokens: maxTokens, temperature, stream: true };
68
+ if (t.effort && !giga) body.reasoning_effort = t.effort;
69
+ if (tools?.length && t.tools) {
70
+ if (giga) { body.functions = tools.map((x) => x.function); body.function_call = 'auto'; }
71
+ else { body.tools = tools; body.tool_choice = 'auto'; }
72
+ }
73
+
74
+ const ctrl = new AbortController();
75
+ let idleTimer;
76
+ let timedOut = false;
77
+ const resetIdle = () => {
78
+ clearTimeout(idleTimer);
79
+ idleTimer = setTimeout(() => { timedOut = true; ctrl.abort(); }, idleMs);
80
+ };
81
+ const onAbort = () => ctrl.abort();
82
+ signal?.addEventListener('abort', onAbort);
83
+ resetIdle();
84
+
85
+ const out = { content: '', reasoning: '', toolCalls: [], finishReason: null };
86
+ // Часть серверов отдаёт размышления внутри content в тегах <think>…</think>
87
+ let inThink = false;
88
+ const pushContent = (text) => {
89
+ let rest = text;
90
+ while (rest) {
91
+ if (inThink) {
92
+ const end = rest.indexOf('</think>');
93
+ const part = end < 0 ? rest : rest.slice(0, end);
94
+ if (part) { out.reasoning += part; onDelta({ reasoning: part }); }
95
+ if (end < 0) break;
96
+ inThink = false;
97
+ rest = rest.slice(end + 8);
98
+ } else {
99
+ const start = rest.indexOf('<think>');
100
+ const part = start < 0 ? rest : rest.slice(0, start);
101
+ if (part) { out.content += part; onDelta({ content: part }); }
102
+ if (start < 0) break;
103
+ inThink = true;
104
+ rest = rest.slice(start + 7);
105
+ }
106
+ }
107
+ };
108
+
109
+ try {
110
+ const res = await providerFetch(t.provider, t.url, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
113
+ body: JSON.stringify(body),
114
+ signal: ctrl.signal,
115
+ });
116
+ if (!res.ok) throw new Error(`ИИ HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
117
+ const dec = new TextDecoder();
118
+ let buf = '';
119
+ for await (const chunk of res.body) {
120
+ resetIdle();
121
+ buf += dec.decode(chunk, { stream: true });
122
+ let nl;
123
+ while ((nl = buf.indexOf('\n')) >= 0) {
124
+ const line = buf.slice(0, nl).trim();
125
+ buf = buf.slice(nl + 1);
126
+ if (!line.startsWith('data:')) continue;
127
+ const data = line.slice(5).trim();
128
+ if (!data || data === '[DONE]') continue;
129
+ let j;
130
+ try { j = JSON.parse(data); } catch { continue; }
131
+ if (j.error) throw new Error(`ИИ: ${j.error.message || JSON.stringify(j.error)}`);
132
+ const c = j.choices?.[0];
133
+ if (!c) continue;
134
+ if (c.finish_reason) out.finishReason = c.finish_reason;
135
+ const d = c.delta || {};
136
+ const r = d.reasoning ?? d.reasoning_content;
137
+ if (r) { out.reasoning += r; onDelta({ reasoning: r }); }
138
+ if (d.content) pushContent(d.content);
139
+ for (const tc of d.tool_calls || []) {
140
+ const x = (out.toolCalls[tc.index ?? 0] ||= { id: '', type: 'function', function: { name: '', arguments: '' } });
141
+ if (tc.id) x.id = tc.id;
142
+ if (tc.function?.name) x.function.name += tc.function.name;
143
+ if (tc.function?.arguments) x.function.arguments += tc.function.arguments;
144
+ }
145
+ if (d.function_call) {
146
+ const x = (out.toolCalls[0] ||= { id: '', type: 'function', function: { name: '', arguments: '' } });
147
+ if (d.function_call.name) x.function.name += d.function_call.name;
148
+ const a = d.function_call.arguments;
149
+ if (a) x.function.arguments += typeof a === 'string' ? a : JSON.stringify(a);
150
+ }
151
+ }
152
+ }
153
+ } catch (e) {
154
+ if (signal?.aborted) throw new Error('Остановлено пользователем');
155
+ if (timedOut) throw new Error(`ИИ не присылал данных ${Math.round(idleMs / 1000)} сек`);
156
+ throw e;
157
+ } finally {
158
+ clearTimeout(idleTimer);
159
+ signal?.removeEventListener('abort', onAbort);
160
+ }
161
+ out.toolCalls = out.toolCalls.filter(Boolean).map((x, i) => ({ ...x, id: x.id || `call_${Date.now()}_${i}` }));
162
+ return out;
163
+ }
164
+
165
+ async function anthropic(t, messages, maxTokens, temperature, onDelta, signal, idleMs) {
166
+ const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n');
167
+ const body = { model: t.model, system, max_tokens: maxTokens, temperature, messages: messages.filter((m) => m.role !== 'system' && m.role !== 'tool') };
168
+ const res = await providerFetch(t.provider, t.url, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify(body),
172
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(idleMs)]) : AbortSignal.timeout(idleMs),
173
+ });
174
+ const text = await res.text();
175
+ if (!res.ok) throw new Error(`ИИ HTTP ${res.status}: ${text.slice(0, 300)}`);
176
+ const j = JSON.parse(text);
177
+ const content = (j.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
178
+ onDelta({ content });
179
+ return { content, reasoning: '', toolCalls: [], finishReason: j.stop_reason };
180
+ }
181
+
182
+ export function extractJson(text) {
183
+ const cleaned = text.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/```(?:json)?/g, '');
184
+ const a = cleaned.indexOf('{'), b = cleaned.lastIndexOf('}');
185
+ if (a < 0 || b <= a) throw new Error('В ответе ИИ нет JSON');
186
+ return JSON.parse(cleaned.slice(a, b + 1));
187
+ }
@@ -0,0 +1,355 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import https from 'node:https';
4
+ import tls from 'node:tls';
5
+ import crypto from 'node:crypto';
6
+ import { Readable } from 'node:stream';
7
+ import { ROOT, llmConfig } from '../config.js';
8
+ import { kvGet, kvSet } from '../db.js';
9
+ import { encryptSecret, decryptSecret } from '../auth/index.js';
10
+ import { chatStream } from './client.js';
11
+
12
+ const PROTOCOLS = ['openai', 'anthropic', 'gigachat', 'ollama'];
13
+ const DEFAULT_URL = {
14
+ openai: '',
15
+ anthropic: 'https://api.anthropic.com/v1/messages',
16
+ gigachat: 'https://gigachat.devices.sberbank.ru/api/v1/chat/completions',
17
+ ollama: 'http://localhost:11434/v1/chat/completions',
18
+ };
19
+ const GIGA_AUTH = 'https://ngw.devices.sberbank.ru:9443/api/v2/oauth';
20
+ const GIGA_SCOPE = 'GIGACHAT_API_PERS';
21
+ const CERT_CODES = new Set(['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'SELF_SIGNED_CERT_IN_CHAIN', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'UNABLE_TO_GET_ISSUER_CERT', 'CERT_UNTRUSTED', 'CERT_HAS_EXPIRED']);
22
+
23
+ // ---------- ключи ----------
24
+ function readSecret(ref) {
25
+ if (!ref) return '';
26
+ if (ref === 'env:LLM') return llmConfig.apiKey;
27
+ if (ref.startsWith('env:')) return (process.env[ref.slice(4)] || '').trim();
28
+ try {
29
+ const raw = fs.readFileSync(path.resolve(ROOT, ref), 'utf8').replace(/^\uFEFF/, '').trim();
30
+ if (!raw.startsWith('{')) return raw;
31
+ const j = JSON.parse(raw);
32
+ return String(j.apiKey || j.api_key || j.token || j.key || '').trim();
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ export function getKey(p) {
39
+ let key = decryptSecret(p.apiKey || '') || readSecret(p.keyFile);
40
+ if (!key && p.protocol === 'gigachat') key = (process.env.GIGACHAT_KEY || '').trim();
41
+ return key;
42
+ }
43
+
44
+ // ---------- адреса ----------
45
+ export function chatUrl(p) {
46
+ const base = String(p.baseUrl || DEFAULT_URL[p.protocol] || '').trim().replace(/\/+$/, '');
47
+ if (!base) return '';
48
+ const tail = p.protocol === 'anthropic' ? '/messages' : '/chat/completions';
49
+ if (base.endsWith(tail)) return base;
50
+ let u;
51
+ try { u = new URL(base); } catch { return base; }
52
+ if (u.pathname === '/' || u.pathname === '') return `${base}/v1${tail}`;
53
+ if (u.pathname.endsWith('/v1')) return base + tail;
54
+ return base;
55
+ }
56
+
57
+ function modelsUrls(p) {
58
+ const url = chatUrl(p);
59
+ let origin;
60
+ try { origin = new URL(url).origin; } catch { return []; }
61
+ if (p.protocol === 'ollama') return [`${origin}/api/tags`, `${origin}/v1/models`];
62
+ const list = [];
63
+ if (url.endsWith('/chat/completions')) list.push(`${url.slice(0, -'/chat/completions'.length)}/models`);
64
+ else if (url.endsWith('/messages')) list.push(`${url.slice(0, -'/messages'.length)}/models`);
65
+ list.push(`${origin}/v1/models`);
66
+ return [...new Set(list)];
67
+ }
68
+
69
+ // ---------- сеть ----------
70
+ // Сертификат НУЦ Минцифры для GigaChat: путь задаётся у подключения или в GIGACHAT_CA_FILE
71
+ const caCache = new Map();
72
+ function caList(p) {
73
+ const file = String(p.caFile || process.env.GIGACHAT_CA_FILE || '').trim();
74
+ if (!file) return null;
75
+ if (!caCache.has(file)) {
76
+ try { caCache.set(file, [...tls.rootCertificates, fs.readFileSync(path.resolve(ROOT, file), 'utf8')]); } catch { caCache.set(file, null); }
77
+ }
78
+ return caCache.get(file);
79
+ }
80
+
81
+ function certError(e) {
82
+ const msg = String(e?.message || e);
83
+ if (CERT_CODES.has(e?.code) || /certificate|self.signed/i.test(msg)) {
84
+ const err = new Error('GigaChat: нужен сертификат НУЦ Минцифры — укажите путь к russian_trusted_root_ca.pem в настройках подключения (поле «Сертификат») или в переменной GIGACHAT_CA_FILE');
85
+ err.fatal = true;
86
+ return err;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ // Запрос с проверкой TLS по корню НУЦ Минцифры — только для GigaChat, остальные соединения не затрагиваются
92
+ function nodeFetch(url, { method = 'GET', headers = {}, body, signal, ca } = {}) {
93
+ return new Promise((resolve, reject) => {
94
+ const data = body === undefined ? null : Buffer.from(body);
95
+ const req = https.request(new URL(url), { method, ca: ca || undefined, headers: { ...headers, ...(data ? { 'Content-Length': data.length } : {}) } }, (res) => {
96
+ const h = new Headers();
97
+ for (const [k, v] of Object.entries(res.headers)) for (const x of [].concat(v)) h.append(k, x);
98
+ resolve(new Response(Readable.toWeb(res), { status: res.statusCode, headers: h }));
99
+ });
100
+ req.on('error', reject);
101
+ const onAbort = () => req.destroy(Object.assign(new Error('aborted'), { name: 'AbortError' }));
102
+ if (signal?.aborted) return onAbort();
103
+ signal?.addEventListener('abort', onAbort, { once: true });
104
+ req.on('close', () => signal?.removeEventListener('abort', onAbort));
105
+ req.end(data);
106
+ });
107
+ }
108
+
109
+ async function rawFetch(p, url, init) {
110
+ try {
111
+ return p.protocol === 'gigachat' ? await nodeFetch(url, { ...init, ca: caList(p) || undefined }) : await fetch(url, init);
112
+ } catch (e) {
113
+ if (e.name === 'AbortError') throw e;
114
+ const ce = p.protocol === 'gigachat' ? certError(e) : null;
115
+ if (ce) throw ce;
116
+ let host = url;
117
+ try { host = new URL(url).host; } catch { /* как есть */ }
118
+ const code = e.cause?.code || e.code;
119
+ const why = code === 'ECONNREFUSED' ? 'отказано в соединении (сервер не запущен?)' : code === 'ENOTFOUND' ? 'адрес не найден' : code || e.cause?.message || e.message;
120
+ const err = new Error(`нет связи с ${host}: ${why}`);
121
+ err.fatal = e.cause?.code === 'ECONNREFUSED';
122
+ throw err;
123
+ }
124
+ }
125
+
126
+ const tokens = new Map();
127
+ async function gigaToken(p) {
128
+ const c = tokens.get(p.id);
129
+ if (c && c.exp - 60000 > Date.now()) return c.token;
130
+ const key = getKey(p).replace(/^Basic\s+/i, '');
131
+ if (!key) throw Object.assign(new Error('GigaChat: не указан ключ авторизации'), { fatal: true });
132
+ const res = await rawFetch(p, p.authUrl || GIGA_AUTH, {
133
+ method: 'POST',
134
+ headers: { Authorization: `Basic ${key}`, RqUID: crypto.randomUUID(), 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
135
+ body: `scope=${encodeURIComponent(p.scope || GIGA_SCOPE)}`,
136
+ signal: AbortSignal.timeout(20000),
137
+ });
138
+ const text = await res.text();
139
+ if (!res.ok) throw Object.assign(new Error(`GigaChat: авторизация не удалась (HTTP ${res.status}): ${text.slice(0, 200)}`), { fatal: res.status < 500 });
140
+ const j = JSON.parse(text);
141
+ if (!j.access_token) throw new Error('GigaChat: сервер не вернул токен доступа');
142
+ tokens.set(p.id, { token: j.access_token, exp: Number(j.expires_at) || Date.now() + 29 * 60000 });
143
+ return j.access_token;
144
+ }
145
+
146
+ async function authHeaders(p) {
147
+ if (p.protocol === 'gigachat') return { Authorization: `Bearer ${await gigaToken(p)}` };
148
+ const key = getKey(p);
149
+ if (!key) return {};
150
+ if (p.protocol === 'anthropic') return { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
151
+ return { Authorization: `Bearer ${key}` };
152
+ }
153
+
154
+ export async function providerFetch(p, url, init = {}) {
155
+ const go = async () => rawFetch(p, url, { ...init, headers: { ...init.headers, ...(await authHeaders(p)) } });
156
+ let res = await go();
157
+ if (res.status === 401 && p.protocol === 'gigachat' && tokens.delete(p.id)) res = await go();
158
+ return res;
159
+ }
160
+
161
+ // ---------- хранилище ----------
162
+ // Подключения живут только в нашей базе. Единственная отправная точка — .env этого проекта
163
+ // (если задан) — чтобы после первого запуска уже чем-то отвечать. Дальше всё настраивается в интерфейсе.
164
+ let ready = false;
165
+ function init() {
166
+ if (ready) return;
167
+ ready = true;
168
+ if (kvGet('llmProviders') === null) kvSet('llmProviders', []);
169
+ ensureEnv();
170
+ // разовая миграция: подключения, созданные до настройки сертификата в интерфейсе, получают прежний путь к сертификату НУЦ (только как данные — можно изменить или удалить)
171
+ if (!kvGet('gigaCaMigrated')) {
172
+ const list = kvGet('llmProviders', []);
173
+ const cert = path.resolve(ROOT, '../work/#system/certs/russian_trusted_root_ca.pem');
174
+ if (fs.existsSync(cert)) for (const p of list) if (p.protocol === 'gigachat' && !p.caFile) p.caFile = cert;
175
+ kvSet('llmProviders', list);
176
+ kvSet('gigaCaMigrated', true);
177
+ }
178
+ ensureEnv();
179
+ const list = kvGet('llmProviders', []);
180
+ if (!kvGet('llmSelected') && list.length) {
181
+ const hit = findEnv(list) || list.flatMap((p) => p.models.map((m) => ({ p, m }))).find(({ p }) => getKey(p) || p.protocol === 'ollama');
182
+ if (hit) kvSet('llmSelected', { providerId: hit.p.id, model: hit.m.id });
183
+ }
184
+ }
185
+
186
+ const load = () => { init(); return kvGet('llmProviders', []); };
187
+ const save = (list) => kvSet('llmProviders', list.map((p) => (p.apiKey ? { ...p, apiKey: encryptSecret(p.apiKey) } : p)));
188
+ const find = (id) => load().find((p) => p.id === id);
189
+ const sameOrigin = (a, b) => { try { return new URL(a).origin === new URL(b).origin; } catch { return false; } };
190
+
191
+ function findEnv(list) {
192
+ if (!llmConfig.model) return null;
193
+ for (const p of list) {
194
+ if (llmConfig.baseUrl && !sameOrigin(chatUrl(p), llmConfig.baseUrl)) continue;
195
+ const m = p.models.find((x) => x.id === llmConfig.model);
196
+ if (m) return { p, m };
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function ensureEnv() {
202
+ if (llmConfig.provider === 'none' || !llmConfig.baseUrl || !llmConfig.model || !llmConfig.apiKey) return;
203
+ const list = kvGet('llmProviders', []);
204
+ const hit = findEnv(list);
205
+ if (hit) {
206
+ if (!hit.m.effort && llmConfig.reasoningEffort) hit.m.effort = llmConfig.reasoningEffort;
207
+ } else {
208
+ list.push({
209
+ id: 'env', name: 'Из .env', protocol: llmConfig.provider === 'anthropic' ? 'anthropic' : 'openai', baseUrl: llmConfig.baseUrl,
210
+ keyFile: 'env:LLM', models: [{ id: llmConfig.model, label: llmConfig.model, tools: llmConfig.provider !== 'anthropic', effort: llmConfig.reasoningEffort || '' }], source: 'env',
211
+ });
212
+ }
213
+ save(list);
214
+ }
215
+
216
+ const cleanModels = (models) => {
217
+ const seen = new Set();
218
+ const out = [];
219
+ for (const m of Array.isArray(models) ? models : []) {
220
+ const id = String(m?.id ?? '').trim();
221
+ if (!id || seen.has(id)) continue;
222
+ seen.add(id);
223
+ out.push({ id, label: String(m.label || id).trim(), tools: m.tools !== false && m.tools !== 'false', ...(m.effort ? { effort: String(m.effort) } : {}) });
224
+ }
225
+ return out;
226
+ };
227
+
228
+ const view = (p) => {
229
+ const key = getKey(p);
230
+ return {
231
+ id: p.id, name: p.name, protocol: p.protocol, baseUrl: p.baseUrl || DEFAULT_URL[p.protocol] || '', authUrl: p.authUrl || '', scope: p.scope || '',
232
+ caFile: p.caFile || '', models: p.models, source: p.source, hasKey: !!key, needsKey: p.protocol !== 'ollama',
233
+ keySource: p.apiKey ? 'введён вручную' : p.keyFile ? (p.keyFile.startsWith('env:') ? '.env' : 'файл') : '',
234
+ editable: true,
235
+ };
236
+ };
237
+
238
+ export const listProviders = () => load().map(view);
239
+ export const getSelected = () => { init(); return kvGet('llmSelected'); };
240
+
241
+ // ---------- управление ----------
242
+ const fieldsOf = (b, p = {}) => {
243
+ const protocol = b.protocol ?? p.protocol;
244
+ if (!PROTOCOLS.includes(protocol)) throw new Error('Неизвестный протокол');
245
+ const name = String(b.name ?? p.name ?? '').trim();
246
+ if (!name) throw new Error('Укажите название');
247
+ const baseUrl = String(b.baseUrl ?? p.baseUrl ?? '').trim() || DEFAULT_URL[protocol];
248
+ if (!baseUrl) throw new Error('Укажите адрес');
249
+ if (!/^https?:\/\//i.test(baseUrl)) throw new Error('Адрес должен начинаться с http:// или https://');
250
+ const out = { ...p, name, protocol, baseUrl };
251
+ if (protocol === 'gigachat') {
252
+ out.authUrl = String(b.authUrl ?? p.authUrl ?? '').trim() || GIGA_AUTH;
253
+ out.scope = String(b.scope ?? p.scope ?? '').trim() || GIGA_SCOPE;
254
+ out.caFile = String(b.caFile ?? p.caFile ?? '').trim();
255
+ if (!out.caFile) delete out.caFile;
256
+ } else {
257
+ delete out.authUrl;
258
+ delete out.scope;
259
+ delete out.caFile;
260
+ }
261
+ if (typeof b.apiKey === 'string' && b.apiKey.trim()) out.apiKey = b.apiKey.trim();
262
+ if (b.clearKey) { delete out.apiKey; delete out.keyFile; }
263
+ if (b.models !== undefined) out.models = cleanModels(b.models);
264
+ return out;
265
+ };
266
+
267
+ export function addProvider(body = {}) {
268
+ const list = load();
269
+ const p = { ...fieldsOf(body), id: `p_${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}`, source: 'manual' };
270
+ p.models ||= [];
271
+ list.push(p);
272
+ save(list);
273
+ return view(p);
274
+ }
275
+
276
+ export function updateProvider(id, body = {}) {
277
+ const list = load();
278
+ const i = list.findIndex((p) => p.id === id);
279
+ if (i < 0) throw new Error('Подключение не найдено');
280
+ list[i] = fieldsOf(body, list[i]);
281
+ save(list);
282
+ tokens.delete(id);
283
+ return view(list[i]);
284
+ }
285
+
286
+ export function deleteProvider(id) {
287
+ const list = load();
288
+ if (!list.some((p) => p.id === id)) throw new Error('Подключение не найдено');
289
+ save(list.filter((p) => p.id !== id));
290
+ tokens.delete(id);
291
+ if (getSelected()?.providerId === id) kvSet('llmSelected', null);
292
+ }
293
+
294
+ export function selectModel(providerId, model) {
295
+ const p = find(providerId);
296
+ if (!p) throw new Error('Подключение не найдено');
297
+ if (!p.models.some((m) => m.id === model)) throw new Error('У подключения нет такой модели — добавьте её в список');
298
+ kvSet('llmSelected', { providerId, model });
299
+ }
300
+
301
+ export async function fetchRemoteModels(id) {
302
+ const p = find(id);
303
+ if (!p) throw new Error('Подключение не найдено');
304
+ let lastErr = 'адрес не задан';
305
+ for (const url of modelsUrls(p)) {
306
+ try {
307
+ const res = await providerFetch(p, url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(15000) });
308
+ const text = await res.text();
309
+ if (!res.ok) { lastErr = `HTTP ${res.status}: ${text.slice(0, 150)}`; continue; }
310
+ const j = JSON.parse(text);
311
+ const ids = (Array.isArray(j.data) ? j.data : Array.isArray(j.models) ? j.models : []).map((m) => String(m?.id || m?.name || m?.model || m || '').trim()).filter(Boolean);
312
+ return [...new Set(ids)].map((mid) => ({ id: mid, added: p.models.some((m) => m.id === mid) }));
313
+ } catch (e) {
314
+ lastErr = e.message;
315
+ if (e.fatal) break;
316
+ }
317
+ }
318
+ throw new Error(`Не удалось получить список моделей: ${lastErr}`);
319
+ }
320
+
321
+ // ---------- активная модель ----------
322
+ const toTarget = (p, m) => ({ provider: p, protocol: p.protocol, url: chatUrl(p), model: m.id, label: m.label || m.id, tools: !!m.tools && p.protocol !== 'anthropic', effort: m.effort || '' });
323
+
324
+ export function resolveActive() {
325
+ const sel = getSelected();
326
+ const p = sel && find(sel.providerId);
327
+ const m = p?.models.find((x) => x.id === sel.model);
328
+ if (p && m) return toTarget(p, m);
329
+ if (llmConfig.provider !== 'none' && llmConfig.baseUrl && llmConfig.model) {
330
+ const protocol = llmConfig.provider === 'anthropic' ? 'anthropic' : 'openai';
331
+ return toTarget({ id: 'env', name: '.env', protocol, baseUrl: llmConfig.baseUrl, keyFile: 'env:LLM' }, { id: llmConfig.model, label: llmConfig.model, tools: true, effort: llmConfig.reasoningEffort });
332
+ }
333
+ return null;
334
+ }
335
+
336
+ export const isReady = (t) => !!t && !!t.url && !!t.model && (t.protocol === 'ollama' || !!getKey(t.provider));
337
+
338
+ export async function testProvider(id, model) {
339
+ const p = find(id);
340
+ if (!p) return { ok: false, ms: 0, error: 'Подключение не найдено' };
341
+ const m = p.models.find((x) => x.id === model) || (model ? { id: model } : p.models[0]);
342
+ if (!m) return { ok: false, ms: 0, error: 'Не выбрана модель' };
343
+ const target = toTarget(p, m);
344
+ if (!isReady(target)) return { ok: false, ms: 0, error: p.protocol === 'gigachat' ? 'Не указан ключ авторизации' : 'Не указан ключ или адрес' };
345
+ const started = Date.now();
346
+ try {
347
+ const r = await chatStream([{ role: 'user', content: 'Ответь одним словом: «готов».' }], { target, maxTokens: 600, temperature: 0, idleMs: 30000 });
348
+ const ms = Date.now() - started;
349
+ const text = r.content.trim();
350
+ if (!text) return { ok: false, ms, error: `пустой ответ (finish_reason=${r.finishReason})` };
351
+ return { ok: true, ms, text: text.slice(0, 200) };
352
+ } catch (e) {
353
+ return { ok: false, ms: Date.now() - started, error: e.message };
354
+ }
355
+ }