kingkont 0.6.2 → 0.7.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.
package/server.js CHANGED
@@ -28,6 +28,11 @@ const KIE_BASE = 'https://api.kie.ai';
28
28
  const ELEVEN_BASE = 'https://api.elevenlabs.io';
29
29
  const ROOT = __dirname;
30
30
 
31
+ // Источник live-настроек (use*-флаги, chatium token). Подставляется через
32
+ // start({ getSettings }) из main.js. Если не передан — fallback на пустые
33
+ // настройки, и server.js работает как раньше через прямые провайдеры.
34
+ let getSettings = () => ({});
35
+
31
36
  const MIME = {
32
37
  '.html': 'text/html; charset=utf-8',
33
38
  '.css': 'text/css; charset=utf-8',
@@ -45,6 +50,142 @@ const MIME = {
45
50
  };
46
51
 
47
52
  // ---------- helpers ----------
53
+ // Логирует каждый исходящий запрос провайдеру. Видно в npm start output —
54
+ // это даёт чёткую картину «куда server.js реально пытается отправить данные».
55
+ function logProviderCall(method, provider, target, extra = '') {
56
+ const ts = new Date().toLocaleTimeString();
57
+ console.log(`[${ts}] → ${method.padEnd(4)} ${provider.padEnd(11)} ${target}${extra ? ' ' + extra : ''}`);
58
+ }
59
+
60
+ // ===== Chatium proxy helpers ==============================================
61
+ // Архитектура: Chatium API асинхронный. POST /api/text|audio|video возвращает
62
+ // taskId сразу, callback @start/sdk через несколько секунд (или минут для
63
+ // видео) пишет результат в proxy_tasks. Здесь мы поллим /api/poll до готовности.
64
+ //
65
+ // Возвращаем результат клиенту в его «синхронном» формате — он не знает о
66
+ // chatium-async. Для long-running'а (видео) клиент сам так задумано работает
67
+ // через /api/generate + /api/poll, поэтому видео мы НЕ ждём здесь, а отдаём
68
+ // клиенту taskId с префиксом «chatium:» и эмулируем KIE poll.
69
+
70
+ const CHATIUM_PATHS = {
71
+ text: '/app/spaces/server/api/text',
72
+ audio: '/app/spaces/server/api/audio',
73
+ image: '/app/spaces/server/api/image',
74
+ video: '/app/spaces/server/api/video',
75
+ poll: '/app/spaces/server/api/poll',
76
+ balance: '/app/spaces/server/api/balance',
77
+ transactions: '/app/spaces/server/api/transactions',
78
+ uploadUrl: '/app/spaces/server/api/upload_url',
79
+ };
80
+
81
+ // CDN-домен Chatium для готовых ссылок после upload. Подтверждено в
82
+ // useImageEditorSave.ts (`https://sel.cdn-chatium.io/get/...`) и
83
+ // в chatium-sync.sh (`https://fs.chatium.ru/get/...`). Оба работают
84
+ // (alias'ы), используем fs.chatium.ru — он public-доступен и фигурирует
85
+ // в getThumbnailUrl-helper'е тоже.
86
+ const CHATIUM_CDN = 'https://fs.chatium.ru/get';
87
+
88
+ async function chatiumStart(s, kind, body) {
89
+ const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS[kind];
90
+ logProviderCall('POST', 'Chatium', url, summarizeBody(body));
91
+ const r = await fetch(url, {
92
+ method: 'POST',
93
+ headers: {
94
+ 'Authorization': `Bearer ${s.chatium.token}`,
95
+ 'Content-Type': 'application/json',
96
+ },
97
+ body: JSON.stringify(body),
98
+ });
99
+ const text = await r.text();
100
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
101
+ if (!r.ok) {
102
+ const reason = data?.reason || data?.error || data?.raw || `HTTP ${r.status}`;
103
+ throw new Error(`Chatium /api/${kind} HTTP ${r.status}: ${String(reason).slice(0, 300)}`);
104
+ }
105
+ if (!data.taskId) throw new Error(`Chatium /api/${kind} вернул без taskId: ${text.slice(0, 200)}`);
106
+ return data.taskId;
107
+ }
108
+
109
+ async function chatiumPoll(s, taskId) {
110
+ const url = `${s.chatium.base.replace(/\/$/, '')}${CHATIUM_PATHS.poll}?taskId=${encodeURIComponent(taskId)}`;
111
+ const r = await fetch(url, {
112
+ headers: { 'Authorization': `Bearer ${s.chatium.token}` },
113
+ });
114
+ const text = await r.text();
115
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
116
+ if (!r.ok) {
117
+ const reason = data?.reason || data?.error || data?.raw || `HTTP ${r.status}`;
118
+ throw new Error(`Chatium /api/poll HTTP ${r.status}: ${String(reason).slice(0, 300)}`);
119
+ }
120
+ return data;
121
+ }
122
+
123
+ /**
124
+ * Поллит до готовности. Используется для коротких генераций (text, audio).
125
+ *
126
+ * Grace-period: для media (audio/image/video) cost приходит в @start через
127
+ * ОТДЕЛЬНЫЙ /onMediaBilled callback, иногда позже /onCompleted. Если task
128
+ * уже completed но creditsCharged ещё null — даём 5 сек чтобы billed
129
+ * подоспел и мы могли отдать клиенту корректную стоимость генерации.
130
+ */
131
+ async function chatiumWait(s, taskId, timeoutMs = 120000, billedGraceMs = 5000) {
132
+ const deadline = Date.now() + timeoutMs;
133
+ let lastStatus = null;
134
+ while (Date.now() < deadline) {
135
+ const d = await chatiumPoll(s, taskId);
136
+ if (d.status !== lastStatus) {
137
+ console.log(`[chatium] task ${taskId.slice(0,8)}… status=${d.status}`);
138
+ lastStatus = d.status;
139
+ }
140
+ if (d.status === 'completed') {
141
+ if (typeof d.creditsCharged === 'number') return d;
142
+ // Status completed, ждём billed-callback (≤ billedGraceMs).
143
+ const graceUntil = Date.now() + billedGraceMs;
144
+ while (Date.now() < graceUntil) {
145
+ await new Promise(s2 => setTimeout(s2, 800));
146
+ const d2 = await chatiumPoll(s, taskId);
147
+ if (typeof d2.creditsCharged === 'number') {
148
+ console.log(`[chatium] task ${taskId.slice(0,8)}… billed=${d2.creditsCharged}`);
149
+ return d2;
150
+ }
151
+ }
152
+ console.log(`[chatium] task ${taskId.slice(0,8)}… billed timeout, cost unknown`);
153
+ return d;
154
+ }
155
+ if (d.status === 'failed') throw new Error(d.error || 'chatium task failed');
156
+ await new Promise(s2 => setTimeout(s2, 1500));
157
+ }
158
+ throw new Error(`chatium poll timeout after ${timeoutMs / 1000}s`);
159
+ }
160
+
161
+ function summarizeBody(body) {
162
+ if (!body) return '';
163
+ const parts = [];
164
+ if (body.model) parts.push(`model=${body.model}`);
165
+ if (body.kind) parts.push(`kind=${body.kind}`);
166
+ if (typeof body.prompt === 'string') parts.push(`prompt=${body.prompt.length}ch`);
167
+ if (typeof body.text === 'string') parts.push(`text=${body.text.length}ch`);
168
+ if (Array.isArray(body.imageInputs)) parts.push(`imgs=${body.imageInputs.length}`);
169
+ return parts.join(' ');
170
+ }
171
+
172
+ /**
173
+ * Скачать URL и стримить в response (для аудио/видео из Chatium —
174
+ * клиент ждёт audio/mpeg, а Chatium отдаёт URL).
175
+ */
176
+ async function streamUrlToResponse(url, res, contentType = 'audio/mpeg', extraHeaders = {}) {
177
+ const r = await fetch(url);
178
+ if (!r.ok) throw new Error(`download ${r.status} for ${url.slice(0, 100)}`);
179
+ res.writeHead(200, { 'Content-Type': contentType, ...extraHeaders });
180
+ const reader = r.body.getReader();
181
+ while (true) {
182
+ const { done, value } = await reader.read();
183
+ if (done) break;
184
+ res.write(Buffer.from(value));
185
+ }
186
+ res.end();
187
+ }
188
+
48
189
  function send(res, status, body, headers = {}) {
49
190
  const isStr = typeof body === 'string';
50
191
  res.writeHead(status, {
@@ -98,11 +239,89 @@ const VIDEO_MODELS = {
98
239
  'seedance-2': 'bytedance/seedance-2',
99
240
  };
100
241
 
242
+ // Маппинг для Chatium-маршрута. У Chatium свой набор моделей через
243
+ // @start/sdk — отличается от KIE: например, у KIE 'seedream/4.5-text-to-image',
244
+ // у Chatium 'bytedance/seedream-5-lite' (с префиксом vendor'а).
245
+ // Источники: spaces/api/execImageRunners.ts, spaces/api/execVideoRunners.ts.
246
+ const CHATIUM_IMAGE_MODELS = {
247
+ 'nano-banana-2': 'nano-banana-2',
248
+ 'nano-banana-pro': 'nano-banana-pro',
249
+ 'grok': 'grok-imagine/text-to-image',
250
+ 'grok-i2i': 'grok-imagine/image-to-image',
251
+ 'seedream-5-lite': 'bytedance/seedream-5-lite',
252
+ };
253
+ const CHATIUM_VIDEO_MODELS = {
254
+ 'seedance-2': 'bytedance/seedance-2',
255
+ 'seedance-2-fast': 'bytedance/seedance-2-fast',
256
+ 'veo-3.1': 'google/veo-3.1',
257
+ 'kling-o1': 'kwaivgi/kling-o1',
258
+ 'kling-3.0': 'kling-3.0/video',
259
+ 'wan-2.7-i2v': 'wan/2-7-image-to-video',
260
+ 'runway': 'runway',
261
+ 'grok-i2v': 'grok-imagine/image-to-video',
262
+ 'grok-t2v': 'grok-imagine/text-to-video',
263
+ };
264
+
101
265
  // ---------- /api/generate ----------
102
266
  async function handleGenerate(req, res) {
103
- const { kind, prompt, imageInputs, videoInputs, aspectRatio, resolution, model: modelKey } = await readJson(req);
267
+ const body = await readJson(req);
268
+ const { kind, prompt, imageInputs, videoInputs, aspectRatio, resolution, duration, model: modelKey } = body;
104
269
  if (!prompt || !prompt.trim()) return send(res, 400, { error: 'prompt обязателен' });
105
270
 
271
+ const s = getSettings();
272
+
273
+ // Chatium-путь для image и video.
274
+ if ((kind === 'video' || kind === 'image') && s.useChatium && s.chatium?.token && s.chatium?.base) {
275
+ // Отдельный mapping для Chatium — у него другой набор моделей.
276
+ const map = kind === 'video' ? CHATIUM_VIDEO_MODELS : CHATIUM_IMAGE_MODELS;
277
+ let fullModel;
278
+ if (modelKey) {
279
+ // Принимаем как короткий ключ (nano-banana-2), так и полный slug
280
+ // (bytedance/seedream-5-lite) — клиент может прислать любой формат.
281
+ fullModel = map[modelKey] || (Object.values(map).includes(modelKey) ? modelKey : null);
282
+ if (!fullModel) {
283
+ const known = Object.keys(map).join(', ');
284
+ return send(res, 400, {
285
+ error: `Модель "${modelKey}" не поддерживается Chatium для ${kind}. Доступны: ${known}. Чтобы использовать KIE-модели — отключите Chatium в настройках.`,
286
+ });
287
+ }
288
+ }
289
+ try {
290
+ const chatiumKind = kind === 'video' ? 'video' : 'image';
291
+ let chatiumBody;
292
+ if (kind === 'video') {
293
+ chatiumBody = { prompt, model: fullModel, imageInputs, videoInputs, aspectRatio, resolution, duration };
294
+ } else {
295
+ // Per-model особенности (см. spaces/api/execImageRunners.ts):
296
+ // - seedream-5-lite: только text-to-image (без imageInputs),
297
+ // output_format только 'jpeg' (не 'jpg').
298
+ // - nano-banana-2: jpg/png; image_input ОК.
299
+ // - grok-imagine: image_input игнорится для text-to-image.
300
+ const isSeedream = fullModel?.includes('seedream');
301
+ chatiumBody = {
302
+ prompt, model: fullModel,
303
+ aspectRatio, resolution,
304
+ outputFormat: isSeedream ? 'jpeg' : 'jpg',
305
+ };
306
+ if (!isSeedream && Array.isArray(imageInputs) && imageInputs.length) {
307
+ chatiumBody.imageInputs = imageInputs;
308
+ }
309
+ }
310
+ const taskId = await chatiumStart(s, chatiumKind, chatiumBody);
311
+ return send(res, 200, { taskId: 'chatium:' + taskId }, { 'X-Provider': 'chatium' });
312
+ } catch (e) {
313
+ logProviderCall('ERR ', 'Chatium', `/api/${kind}`, e.message);
314
+ return send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
315
+ }
316
+ }
317
+
318
+ // Прямой KIE (image и video).
319
+ if (!s.useKie) {
320
+ return send(res, 503, { error: kind === 'video'
321
+ ? 'Видео-коннектор отключён. Включите Chatium или KIE.'
322
+ : 'Картиночный коннектор отключён. Включите Chatium или KIE.' });
323
+ }
324
+
106
325
  const input = { prompt };
107
326
  let model;
108
327
 
@@ -138,53 +357,149 @@ async function handleGenerate(req, res) {
138
357
  if (videoInputs?.length) input.reference_video_urls = videoInputs;
139
358
  if (aspectRatio) input.aspect_ratio = aspectRatio;
140
359
  if (resolution) input.resolution = resolution;
360
+ if (duration) input.duration = +duration;
141
361
  } else {
142
362
  return send(res, 400, { error: `unknown kind: ${kind}` });
143
363
  }
144
364
 
365
+ logProviderCall('POST', 'KIE', `${KIE_BASE}/api/v1/jobs/createTask`, `model=${model} kind=${kind}`);
145
366
  const data = await kieFetch('/api/v1/jobs/createTask', {
146
367
  method: 'POST',
147
368
  body: JSON.stringify({ model, input }),
148
369
  });
149
- send(res, 200, { taskId: data.data?.taskId });
370
+ send(res, 200, { taskId: data.data?.taskId }, { 'X-Provider': 'kie' });
150
371
  }
151
372
 
152
373
  // ---------- /api/poll ----------
153
374
  async function handlePoll(res, url) {
154
375
  const taskId = url.searchParams.get('taskId');
155
376
  if (!taskId) return send(res, 400, { error: 'нужен taskId' });
377
+
378
+ // Chatium-задача — определяем по префиксу «chatium:» который мы выставили
379
+ // в handleGenerate. Делегируем на chatiumPoll, мапим формат под клиент
380
+ // (он ждёт {status: 'done'|'error'|'pending', url, error, state}).
381
+ if (taskId.startsWith('chatium:')) {
382
+ const realId = taskId.slice('chatium:'.length);
383
+ const s = getSettings();
384
+ if (!s.chatium?.token || !s.chatium?.base) {
385
+ return send(res, 401, { status: 'error', error: 'Нет Chatium-сессии' }, { 'X-Provider': 'chatium' });
386
+ }
387
+ try {
388
+ let d = await chatiumPoll(s, realId);
389
+ const provider = { 'X-Provider': 'chatium' };
390
+ if (d.status === 'completed') {
391
+ const url2 = d.result?.urls?.[0];
392
+ if (!url2) return send(res, 200, { status: 'error', error: 'Chatium вернул без URL' }, provider);
393
+ // Билинг приходит ОТДЕЛЬНЫМ callback'ом. Если creditsCharged ещё
394
+ // null, ждём до 5 сек — обычно billed подоспеет за секунду-две.
395
+ if (typeof d.creditsCharged !== 'number') {
396
+ const graceUntil = Date.now() + 5000;
397
+ while (Date.now() < graceUntil) {
398
+ await new Promise(r => setTimeout(r, 800));
399
+ const d2 = await chatiumPoll(s, realId);
400
+ if (typeof d2.creditsCharged === 'number') { d = d2; break; }
401
+ }
402
+ }
403
+ return send(res, 200, {
404
+ status: 'done',
405
+ url: url2,
406
+ cost: typeof d.creditsCharged === 'number' ? d.creditsCharged : null,
407
+ }, provider);
408
+ }
409
+ if (d.status === 'failed') {
410
+ return send(res, 200, { status: 'error', error: d.error || 'failed' }, provider);
411
+ }
412
+ return send(res, 200, { status: 'pending', state: d.status }, provider);
413
+ } catch (e) {
414
+ return send(res, 502, { status: 'error', error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
415
+ }
416
+ }
417
+
418
+ // KIE-задача (старый путь).
419
+ logProviderCall('GET ', 'KIE', `${KIE_BASE}/api/v1/jobs/recordInfo`, `taskId=${taskId}`);
156
420
  const data = await kieFetch(`/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`);
157
421
  const d = data.data || {};
158
422
  const st = d.state;
159
423
 
424
+ const provider = { 'X-Provider': 'kie' };
160
425
  if (st === 'success') {
161
426
  let urls = [];
162
427
  try {
163
428
  const parsed = JSON.parse(d.resultJson || '{}');
164
429
  urls = parsed.resultUrls || [];
165
430
  } catch {}
166
- if (!urls.length) return send(res, 200, { status: 'error', error: 'KIE вернул успех, но без URL' });
167
- send(res, 200, { status: 'done', url: urls[0] });
431
+ if (!urls.length) return send(res, 200, { status: 'error', error: 'KIE вернул успех, но без URL' }, provider);
432
+ send(res, 200, { status: 'done', url: urls[0] }, provider);
168
433
  } else if (st === 'fail') {
169
- send(res, 200, { status: 'error', error: d.failMsg || d.failCode || 'generation failed' });
434
+ send(res, 200, { status: 'error', error: d.failMsg || d.failCode || 'generation failed' }, provider);
170
435
  } else {
171
- send(res, 200, { status: 'pending', state: st });
436
+ send(res, 200, { status: 'pending', state: st }, provider);
172
437
  }
173
438
  }
174
439
 
175
- // ---------- /api/upload (загружает файл в KIE, возвращает публичный URL) ----------
440
+ // ---------- /api/upload (Chatium-storage ИЛИ KIE) ----------
176
441
  async function handleUpload(req, res) {
177
442
  const filename = decodeURIComponent(req.headers['x-file-name'] || 'upload.bin');
178
443
  const mimeType = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
179
444
  const buf = await readBody(req);
180
445
  if (!buf.length) return send(res, 400, { error: 'пустой файл' });
181
- // KIE base64 endpoint рекомендован для файлов < 10MB
446
+ if (buf.length > 50 * 1024 * 1024) {
447
+ return send(res, 413, { error: `файл слишком большой (${(buf.length/1024/1024).toFixed(1)} MB), лимит 50MB` });
448
+ }
449
+
450
+ const s = getSettings();
451
+
452
+ // Chatium-путь: получаем upload-URL → POST multipart Filedata → hash → URL.
453
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
454
+ try {
455
+ // 1) Берём свежий upload-URL (он одноразовый, см. obtainStorageFilePutUrl).
456
+ const urlRequest = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.uploadUrl;
457
+ const ru = await fetch(urlRequest, {
458
+ headers: { 'Authorization': `Bearer ${s.chatium.token}` },
459
+ });
460
+ if (!ru.ok) {
461
+ const t = await ru.text();
462
+ return send(res, ru.status, { error: 'Chatium upload-url: ' + t.slice(0, 200) }, { 'X-Provider': 'chatium' });
463
+ }
464
+ const { uploadUrl } = await ru.json();
465
+ if (!uploadUrl) {
466
+ return send(res, 502, { error: 'Chatium не вернул uploadUrl' }, { 'X-Provider': 'chatium' });
467
+ }
468
+ // 2) Multipart-POST с полем "Filedata" — формат Chatium-fileservice.
469
+ logProviderCall('POST', 'Chatium', uploadUrl, `file=${filename} size=${buf.length}`);
470
+ const fd = new FormData();
471
+ fd.append('Filedata', new Blob([buf], { type: mimeType }), filename);
472
+ const ru2 = await fetch(uploadUrl, { method: 'POST', body: fd });
473
+ const hashOrErr = (await ru2.text()).trim();
474
+ if (!ru2.ok || !hashOrErr) {
475
+ return send(res, ru2.status || 502, {
476
+ error: `Chatium upload HTTP ${ru2.status}: ${hashOrErr.slice(0, 200)}`,
477
+ }, { 'X-Provider': 'chatium' });
478
+ }
479
+ // Response = plain text hash.
480
+ return send(res, 200, {
481
+ url: `${CHATIUM_CDN}/${hashOrErr}`,
482
+ fileName: filename,
483
+ size: buf.length,
484
+ hash: hashOrErr,
485
+ }, { 'X-Provider': 'chatium' });
486
+ } catch (e) {
487
+ logProviderCall('ERR ', 'Chatium', '/api/upload', e.message);
488
+ return send(res, 502, { error: 'Chatium upload: ' + e.message }, { 'X-Provider': 'chatium' });
489
+ }
490
+ }
491
+
492
+ // Прямой KIE (fallback).
493
+ if (!s.useKie) {
494
+ return send(res, 503, { error: 'Upload-коннектор отключён. Включите Chatium или KIE.' });
495
+ }
182
496
  if (buf.length > 10 * 1024 * 1024) {
183
- return send(res, 413, { error: `файл слишком большой (${(buf.length/1024/1024).toFixed(1)} MB), лимит 10MB` });
497
+ return send(res, 413, { error: `файл слишком большой для KIE (${(buf.length/1024/1024).toFixed(1)} MB), лимит 10MB` });
184
498
  }
185
499
  const dataUrl = `data:${mimeType};base64,${buf.toString('base64')}`;
186
500
  const key = process.env.KIE_API_KEY;
187
501
  if (!key) return send(res, 500, { error: 'KIE_API_KEY не задан' });
502
+ logProviderCall('POST', 'KIE', 'https://kieai.redpandaai.co/api/file-base64-upload', `file=${filename} size=${buf.length}`);
188
503
  const r = await fetch('https://kieai.redpandaai.co/api/file-base64-upload', {
189
504
  method: 'POST',
190
505
  headers: {
@@ -202,7 +517,7 @@ async function handleUpload(req, res) {
202
517
  if (!r.ok || !data.success) {
203
518
  return send(res, r.status || 500, { error: data.msg || `upload failed: ${text.slice(0, 200)}` });
204
519
  }
205
- send(res, 200, { url: data.data.downloadUrl, fileName: data.data.fileName, size: data.data.fileSize });
520
+ send(res, 200, { url: data.data.downloadUrl, fileName: data.data.fileName, size: data.data.fileSize }, { 'X-Provider': 'kie' });
206
521
  }
207
522
 
208
523
  // ---------- /api/proxy?url=... (стрим бинарных данных через бэк, минуя CORS) ----------
@@ -232,6 +547,7 @@ async function handleProxy(res, url) {
232
547
  async function handleVoices(req, res) {
233
548
  const key = process.env.ELEVENLABS_API_KEY;
234
549
  if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
550
+ logProviderCall('GET ', 'ElevenLabs', `${ELEVEN_BASE}/v1/voices`);
235
551
  const r = await fetch(`${ELEVEN_BASE}/v1/voices`, { headers: { 'xi-api-key': key } });
236
552
  const text = await r.text();
237
553
  let data; try { data = JSON.parse(text); } catch { data = {}; }
@@ -243,18 +559,49 @@ async function handleVoices(req, res) {
243
559
  labels: v.labels || {},
244
560
  preview: v.preview_url,
245
561
  }));
246
- send(res, 200, { voices });
562
+ send(res, 200, { voices }, { 'X-Provider': 'elevenlabs' });
247
563
  }
248
564
 
249
- // ---------- /api/text (OpenRouter chat completion) ----------
565
+ // ---------- /api/text (Chatium proxy ИЛИ OpenRouter) ----------
250
566
  // Тело: {prompt, model?, system?, images?: [{url}]}
251
- // images.url — data:image/...;base64,... либо http(s) URL. Если массив не
252
- // пустой, отправляем content[] с image_url-блоками (vision).
253
567
  async function handleText(req, res) {
568
+ const body = await readJson(req);
569
+ const { prompt, model = 'anthropic/claude-sonnet-4', system, images } = body;
570
+ if (!prompt) return send(res, 400, { error: 'нужен prompt' });
571
+
572
+ const s = getSettings();
573
+
574
+ // Chatium-путь: useChatium=true И есть валидный токен.
575
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
576
+ try {
577
+ // Chatium ждёт messages в @start/sdk-формате (image как { type: 'image',
578
+ // source: { type: 'url', url } }). Если у клиента images — конвертируем.
579
+ const chatiumBody = {
580
+ prompt,
581
+ model,
582
+ system,
583
+ images: Array.isArray(images) ? images.filter(i => i?.url) : undefined,
584
+ };
585
+ const taskId = await chatiumStart(s, 'text', chatiumBody);
586
+ const result = await chatiumWait(s, taskId, 120000);
587
+ return send(res, 200, {
588
+ text: result.result?.text || '',
589
+ model,
590
+ cost: typeof result.creditsCharged === 'number' ? result.creditsCharged : null,
591
+ }, { 'X-Provider': 'chatium' });
592
+ } catch (e) {
593
+ logProviderCall('ERR ', 'Chatium', '/api/text', e.message);
594
+ return send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
595
+ }
596
+ }
597
+
598
+ // Прямой OpenRouter (если useOpenrouter включён).
599
+ if (!s.useOpenrouter) {
600
+ return send(res, 503, { error: 'Текстовый коннектор отключён в настройках. Включите Chatium или OpenRouter.' });
601
+ }
254
602
  const key = process.env.OPENROUTER_API_KEY;
255
603
  if (!key) return send(res, 500, { error: 'OPENROUTER_API_KEY не задан' });
256
- const { prompt, model = 'anthropic/claude-sonnet-4', system, images } = await readJson(req);
257
- if (!prompt) return send(res, 400, { error: 'нужен prompt' });
604
+
258
605
  const messages = [];
259
606
  if (system) messages.push({ role: 'system', content: system });
260
607
  if (Array.isArray(images) && images.length) {
@@ -267,6 +614,7 @@ async function handleText(req, res) {
267
614
  } else {
268
615
  messages.push({ role: 'user', content: prompt });
269
616
  }
617
+ logProviderCall('POST', 'OpenRouter', 'https://openrouter.ai/api/v1/chat/completions', `model=${model} prompt=${prompt.length}ch images=${images?.length || 0}`);
270
618
  const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
271
619
  method: 'POST',
272
620
  headers: {
@@ -283,17 +631,74 @@ async function handleText(req, res) {
283
631
  return send(res, r.status, { error: data?.error?.message || data?.raw || `HTTP ${r.status}` });
284
632
  }
285
633
  const content = data?.choices?.[0]?.message?.content || '';
286
- send(res, 200, { text: content, model: data?.model || model });
634
+ send(res, 200, { text: content, model: data?.model || model }, { 'X-Provider': 'openrouter' });
635
+ }
636
+
637
+ // ---------- /api/transactions (только Chatium) — история списаний ----------
638
+ async function handleTransactions(req, res) {
639
+ const s = getSettings();
640
+ if (!s.useChatium || !s.chatium?.token || !s.chatium?.base) {
641
+ return send(res, 503, { error: 'Chatium не подключён' });
642
+ }
643
+ const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.transactions;
644
+ logProviderCall('GET ', 'Chatium', url);
645
+ try {
646
+ const r = await fetch(url, { headers: { 'Authorization': `Bearer ${s.chatium.token}` } });
647
+ const text = await r.text();
648
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
649
+ if (!r.ok) {
650
+ return send(res, r.status, {
651
+ error: data?.reason || data?.error || `HTTP ${r.status}`,
652
+ }, { 'X-Provider': 'chatium' });
653
+ }
654
+ send(res, 200, data, { 'X-Provider': 'chatium' });
655
+ } catch (e) {
656
+ send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
657
+ }
287
658
  }
288
659
 
289
- // ---------- /api/sfx (ElevenLabs Sound Effects) ----------
660
+ // ---------- /api/balance (только Chatium) ----------
661
+ async function handleBalance(req, res) {
662
+ const s = getSettings();
663
+ if (!s.useChatium || !s.chatium?.token || !s.chatium?.base) {
664
+ return send(res, 503, { error: 'Chatium не подключён' });
665
+ }
666
+ const url = s.chatium.base.replace(/\/$/, '') + CHATIUM_PATHS.balance;
667
+ logProviderCall('GET ', 'Chatium', url);
668
+ try {
669
+ const r = await fetch(url, {
670
+ headers: { 'Authorization': `Bearer ${s.chatium.token}` },
671
+ });
672
+ const text = await r.text();
673
+ let data; try { data = JSON.parse(text); } catch { data = { raw: text }; }
674
+ if (!r.ok) {
675
+ return send(res, r.status, {
676
+ error: data?.reason || data?.error || `HTTP ${r.status}`,
677
+ }, { 'X-Provider': 'chatium' });
678
+ }
679
+ send(res, 200, data, { 'X-Provider': 'chatium' });
680
+ } catch (e) {
681
+ send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
682
+ }
683
+ }
684
+
685
+ // ---------- /api/sfx (Chatium ИЛИ ElevenLabs Sound Effects) ----------
290
686
  async function handleSfx(req, res) {
291
- const key = process.env.ELEVENLABS_API_KEY;
292
- if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
293
687
  const { text, durationSeconds, promptInfluence = 0.3 } = await readJson(req);
294
688
  if (!text) return send(res, 400, { error: 'нужен text' });
689
+ const s = getSettings();
690
+
691
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
692
+ return handleAudioViaChatium(res, s, { kind: 'sfx', text, durationSeconds });
693
+ }
694
+ if (!s.useElevenlabs) {
695
+ return send(res, 503, { error: 'Аудио-коннектор отключён. Включите Chatium или ElevenLabs.' });
696
+ }
697
+ const key = process.env.ELEVENLABS_API_KEY;
698
+ if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
295
699
  const body = { text, prompt_influence: promptInfluence };
296
700
  if (durationSeconds) body.duration_seconds = +durationSeconds;
701
+ logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/sound-generation`, `text=${text.length}ch dur=${durationSeconds || '-'}`);
297
702
  const r = await fetch(`${ELEVEN_BASE}/v1/sound-generation`, {
298
703
  method: 'POST',
299
704
  headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
@@ -303,24 +708,27 @@ async function handleSfx(req, res) {
303
708
  const t = await r.text();
304
709
  return send(res, r.status, { error: t.slice(0, 400) });
305
710
  }
306
- res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
307
- const reader = r.body.getReader();
308
- while (true) {
309
- const { done, value } = await reader.read();
310
- if (done) break;
311
- res.write(Buffer.from(value));
312
- }
313
- res.end();
711
+ res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
712
+ await streamReaderToResponse(r.body.getReader(), res);
314
713
  }
315
714
 
316
- // ---------- /api/music (ElevenLabs Music) ----------
715
+ // ---------- /api/music (Chatium ИЛИ ElevenLabs Music) ----------
317
716
  async function handleMusic(req, res) {
318
- const key = process.env.ELEVENLABS_API_KEY;
319
- if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
320
717
  const { prompt, durationMs } = await readJson(req);
321
718
  if (!prompt) return send(res, 400, { error: 'нужен prompt' });
719
+ const s = getSettings();
720
+
721
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
722
+ return handleAudioViaChatium(res, s, { kind: 'music', prompt, durationMs });
723
+ }
724
+ if (!s.useElevenlabs) {
725
+ return send(res, 503, { error: 'Аудио-коннектор отключён. Включите Chatium или ElevenLabs.' });
726
+ }
727
+ const key = process.env.ELEVENLABS_API_KEY;
728
+ if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
322
729
  const body = { prompt };
323
730
  if (durationMs) body.music_length_ms = +durationMs;
731
+ logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/music`, `prompt=${prompt.length}ch dur_ms=${durationMs || '-'}`);
324
732
  const r = await fetch(`${ELEVEN_BASE}/v1/music`, {
325
733
  method: 'POST',
326
734
  headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
@@ -330,22 +738,25 @@ async function handleMusic(req, res) {
330
738
  const t = await r.text();
331
739
  return send(res, r.status, { error: t.slice(0, 400) });
332
740
  }
333
- res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
334
- const reader = r.body.getReader();
335
- while (true) {
336
- const { done, value } = await reader.read();
337
- if (done) break;
338
- res.write(Buffer.from(value));
339
- }
340
- res.end();
741
+ res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
742
+ await streamReaderToResponse(r.body.getReader(), res);
341
743
  }
342
744
 
343
- // ---------- /api/tts (ElevenLabs v3) ----------
745
+ // ---------- /api/tts (Chatium ИЛИ ElevenLabs v3) ----------
344
746
  async function handleTts(req, res) {
345
- const key = process.env.ELEVENLABS_API_KEY;
346
- if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
347
747
  const { text, voiceId = 'JBFqnCBsd6RMkjVDRZzb', modelId = 'eleven_v3' } = await readJson(req);
348
748
  if (!text) return send(res, 400, { error: 'нужен text' });
749
+ const s = getSettings();
750
+
751
+ if (s.useChatium && s.chatium?.token && s.chatium?.base) {
752
+ return handleAudioViaChatium(res, s, { kind: 'tts', text, voice: voiceId, model: modelId });
753
+ }
754
+ if (!s.useElevenlabs) {
755
+ return send(res, 503, { error: 'Аудио-коннектор отключён. Включите Chatium или ElevenLabs.' });
756
+ }
757
+ const key = process.env.ELEVENLABS_API_KEY;
758
+ if (!key) return send(res, 500, { error: 'ELEVENLABS_API_KEY не задан' });
759
+ logProviderCall('POST', 'ElevenLabs', `${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, `model=${modelId} text=${text.length}ch`);
349
760
  const r = await fetch(`${ELEVEN_BASE}/v1/text-to-speech/${voiceId}`, {
350
761
  method: 'POST',
351
762
  headers: { 'xi-api-key': key, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
@@ -355,8 +766,37 @@ async function handleTts(req, res) {
355
766
  const t = await r.text();
356
767
  return send(res, r.status, { error: t.slice(0, 300) });
357
768
  }
358
- res.writeHead(200, { 'Content-Type': 'audio/mpeg' });
359
- const reader = r.body.getReader();
769
+ res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'X-Provider': 'elevenlabs' });
770
+ await streamReaderToResponse(r.body.getReader(), res);
771
+ }
772
+
773
+ // Шлём audio-задачу в Chatium /api/audio, ждём готовности, скачиваем URL и
774
+ // стримим клиенту как audio/mpeg (клиент ожидает blob, не URL).
775
+ async function handleAudioViaChatium(res, s, body) {
776
+ try {
777
+ const taskId = await chatiumStart(s, 'audio', body);
778
+ const result = await chatiumWait(s, taskId, 180000);
779
+ const url = result.result?.urls?.[0];
780
+ if (!url) {
781
+ return send(res, 502, { error: 'Chatium завершил аудио-задачу без URL результата' }, { 'X-Provider': 'chatium' });
782
+ }
783
+ logProviderCall('GET ', 'Chatium', url, '(downloading audio)');
784
+ const headers = { 'X-Provider': 'chatium' };
785
+ if (typeof result.creditsCharged === 'number') {
786
+ headers['X-Cost-Credits'] = String(result.creditsCharged);
787
+ }
788
+ await streamUrlToResponse(url, res, 'audio/mpeg', headers);
789
+ } catch (e) {
790
+ logProviderCall('ERR ', 'Chatium', '/api/audio', e.message);
791
+ if (!res.headersSent) {
792
+ send(res, 502, { error: 'Chatium: ' + e.message }, { 'X-Provider': 'chatium' });
793
+ } else {
794
+ res.end();
795
+ }
796
+ }
797
+ }
798
+
799
+ async function streamReaderToResponse(reader, res) {
360
800
  while (true) {
361
801
  const { done, value } = await reader.read();
362
802
  if (done) break;
@@ -395,6 +835,8 @@ const server = createServer(async (req, res) => {
395
835
  if (req.method === 'POST' && url.pathname === '/api/sfx') return handleSfx(req, res);
396
836
  if (req.method === 'POST' && url.pathname === '/api/music') return handleMusic(req, res);
397
837
  if (req.method === 'POST' && url.pathname === '/api/text') return handleText(req, res);
838
+ if (req.method === 'GET' && url.pathname === '/api/balance') return handleBalance(req, res);
839
+ if (req.method === 'GET' && url.pathname === '/api/transactions') return handleTransactions(req, res);
398
840
  if (req.method === 'GET') return serveStatic(res, url);
399
841
  send(res, 404, 'not found');
400
842
  } catch (e) {
@@ -407,7 +849,10 @@ const server = createServer(async (req, res) => {
407
849
  process.on('unhandledRejection', (err) => console.error('[unhandledRejection]', err));
408
850
  process.on('uncaughtException', (err) => console.error('[uncaughtException]', err));
409
851
 
410
- function start(port = PORT) {
852
+ function start(port = PORT, opts = {}) {
853
+ if (typeof opts.getSettings === 'function') {
854
+ getSettings = opts.getSettings;
855
+ }
411
856
  return new Promise((resolveOk, reject) => {
412
857
  server.once('error', reject);
413
858
  server.listen(port, () => {
@@ -415,6 +860,8 @@ function start(port = PORT) {
415
860
  console.log(`▶ http://localhost:${addr.port}`);
416
861
  console.log(` KIE_API_KEY: ${process.env.KIE_API_KEY ? '✓' : '✗ missing'}`);
417
862
  console.log(` ELEVENLABS_API_KEY: ${process.env.ELEVENLABS_API_KEY ? '✓' : '✗ missing'}`);
863
+ const s = getSettings();
864
+ console.log(` providers: chatium=${s.useChatium !== false ? '✓' : '✗'} openrouter=${s.useOpenrouter !== false ? '✓' : '✗'} elevenlabs=${s.useElevenlabs !== false ? '✓' : '✗'} kie=${s.useKie !== false ? '✓' : '✗'}`);
418
865
  resolveOk(addr.port);
419
866
  });
420
867
  });