Blame Raw
Cohee · 51ad27fb · · 939 lines (33.0 KB)
2 contributors
1import fs from 'node:fs';
2import { Buffer } from 'node:buffer';
3
4import fetch from 'node-fetch';
5import FormData from 'form-data';
6import express from 'express';
7
8import { getConfigValue, mergeObjectWithYaml, excludeKeysByYaml, trimV1, delay } from '../util.js';
9import { setAdditionalHeaders } from '../additional-headers.js';
10import { readSecret, SECRET_KEYS } from './secrets.js';
11import { AIMLAPI_HEADERS, OPENROUTER_HEADERS, SILICONFLOW_ENDPOINT, ZAI_ENDPOINT } from '../constants.js';
12
13export const router = express.Router();
14
15router.post('/caption-image', async (request, response) => {
16 try {
17 let key = '';
18 let headers = {};
19 let bodyParams = {};
20
21 if (request.body.api === 'openai' && !request.body.reverse_proxy) {
22 key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
23 }
24
25 if (request.body.api === 'xai' && !request.body.reverse_proxy) {
26 key = readSecret(request.user.directories, SECRET_KEYS.XAI);
27 }
28
29 if (request.body.api === 'mistral' && !request.body.reverse_proxy) {
30 key = readSecret(request.user.directories, SECRET_KEYS.MISTRALAI);
31 }
32
33 if (request.body.reverse_proxy && request.body.proxy_password) {
34 key = request.body.proxy_password;
35 }
36
37 if (request.body.api === 'custom') {
38 key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
39 mergeObjectWithYaml(bodyParams, request.body.custom_include_body);
40 mergeObjectWithYaml(headers, request.body.custom_include_headers);
41 }
42
43 if (request.body.api === 'openrouter') {
44 key = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER);
45 }
46
47 if (request.body.api === 'ooba') {
48 key = readSecret(request.user.directories, SECRET_KEYS.OOBA);
49 bodyParams.temperature = 0.1;
50 }
51
52 if (request.body.api === 'koboldcpp') {
53 key = readSecret(request.user.directories, SECRET_KEYS.KOBOLDCPP);
54 }
55
56 if (request.body.api === 'llamacpp') {
57 key = readSecret(request.user.directories, SECRET_KEYS.LLAMACPP);
58 }
59
60 if (request.body.api === 'vllm') {
61 key = readSecret(request.user.directories, SECRET_KEYS.VLLM);
62 }
63
64 if (request.body.api === 'aimlapi') {
65 key = readSecret(request.user.directories, SECRET_KEYS.AIMLAPI);
66 }
67
68 if (request.body.api === 'groq') {
69 key = readSecret(request.user.directories, SECRET_KEYS.GROQ);
70 }
71
72 if (request.body.api === 'cohere') {
73 key = readSecret(request.user.directories, SECRET_KEYS.COHERE);
74 }
75
76 if (request.body.api === 'moonshot' && !request.body.reverse_proxy) {
77 key = readSecret(request.user.directories, SECRET_KEYS.MOONSHOT);
78 }
79
80 if (request.body.api === 'nanogpt') {
81 key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
82 }
83
84 if (request.body.api === 'chutes') {
85 key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
86 }
87
88 if (request.body.api === 'electronhub') {
89 key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
90 }
91
92 if (request.body.api === 'zai' && !request.body.reverse_proxy) {
93 key = readSecret(request.user.directories, SECRET_KEYS.ZAI);
94 }
95
96 if (request.body.api === 'zai') {
97 bodyParams.max_tokens = 4096; // default is 1024
98 }
99
100 if (request.body.api === 'pollinations') {
101 key = readSecret(request.user.directories, SECRET_KEYS.POLLINATIONS);
102 bodyParams.seed = Math.floor(Math.random() * Math.pow(2, 32));
103 }
104
105 if (request.body.api === 'workers_ai') {
106 key = readSecret(request.user.directories, SECRET_KEYS.WORKERS_AI);
107 }
108
109 const noKeyTypes = ['custom', 'ooba', 'koboldcpp', 'vllm', 'llamacpp'];
110 if (!key && !request.body.reverse_proxy && !noKeyTypes.includes(request.body.api)) {
111 console.warn('No key found for API', request.body.api);
112 return response.sendStatus(400);
113 }
114
115 const body = {
116 model: request.body.model,
117 messages: [
118 {
119 role: 'user',
120 content: [
121 { type: 'text', text: request.body.prompt },
122 { type: 'image_url', image_url: { 'url': request.body.image } },
123 ],
124 },
125 ],
126 ...bodyParams,
127 };
128
129 const captionSystemPrompt = getConfigValue('openai.captionSystemPrompt');
130 if (captionSystemPrompt) {
131 body.messages.unshift({
132 role: 'system',
133 content: captionSystemPrompt,
134 });
135 }
136
137 if (request.body.api === 'custom') {
138 excludeKeysByYaml(body, request.body.custom_exclude_body);
139 }
140
141 let apiUrl = '';
142
143 if (request.body.api === 'openrouter') {
144 apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
145 Object.assign(headers, OPENROUTER_HEADERS);
146 }
147
148 if (request.body.api === 'openai') {
149 apiUrl = 'https://api.openai.com/v1/chat/completions';
150 }
151
152 if (request.body.reverse_proxy) {
153 apiUrl = `${request.body.reverse_proxy}/chat/completions`;
154 }
155
156 if (request.body.api === 'custom') {
157 apiUrl = `${request.body.server_url}/chat/completions`;
158 }
159
160 if (request.body.api === 'aimlapi') {
161 apiUrl = 'https://api.aimlapi.com/v1/chat/completions';
162 Object.assign(headers, AIMLAPI_HEADERS);
163 }
164
165 if (request.body.api === 'groq') {
166 apiUrl = 'https://api.groq.com/openai/v1/chat/completions';
167 if (body.messages?.[0]?.role === 'system') {
168 body.messages[0].role = 'user';
169 }
170 }
171
172 if (request.body.api === 'mistral') {
173 apiUrl = 'https://api.mistral.ai/v1/chat/completions';
174 }
175
176 if (request.body.api === 'cohere') {
177 apiUrl = 'https://api.cohere.ai/v2/chat';
178 }
179
180 if (request.body.api === 'xai') {
181 apiUrl = 'https://api.x.ai/v1/chat/completions';
182 }
183
184 if (request.body.api === 'pollinations') {
185 apiUrl = 'https://gen.pollinations.ai/v1/chat/completions';
186 }
187
188 if (request.body.api === 'moonshot' && !request.body.reverse_proxy) {
189 apiUrl = 'https://api.moonshot.ai/v1/chat/completions';
190 }
191
192 if (request.body.api === 'nanogpt') {
193 apiUrl = 'https://nano-gpt.com/api/v1/chat/completions';
194 }
195
196 if (request.body.api === 'chutes') {
197 apiUrl = 'https://llm.chutes.ai/v1/chat/completions';
198 }
199
200 if (request.body.api === 'electronhub') {
201 apiUrl = 'https://api.electronhub.ai/v1/chat/completions';
202 }
203
204 if (request.body.api === 'zai' && !request.body.reverse_proxy) {
205 apiUrl = request.body.zai_endpoint === ZAI_ENDPOINT.CODING
206 ? 'https://api.z.ai/api/coding/paas/v4/chat/completions'
207 : 'https://api.z.ai/api/paas/v4/chat/completions';
208 }
209
210 // Handle video inlining for Z.AI
211 if (request.body.api === 'zai' && /data:video\/\w+;base64,/.test(request.body.image)) {
212 const message = body.messages.find(msg => Array.isArray(msg.content));
213 if (message) {
214 const imgContent = message.content.find(c => c.type === 'image_url');
215 if (imgContent) {
216 imgContent.type = 'video_url';
217 imgContent.video_url = imgContent.image_url;
218 delete imgContent.image_url;
219 }
220 }
221 }
222
223 if (request.body.api === 'workers_ai') {
224 const accountId = String(request.body.workers_ai_account_id || '').trim();
225 if (!accountId) {
226 return response.status(400).send({ error: 'Cloudflare Workers AI Account ID is required' });
227 }
228 apiUrl = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1/chat/completions`;
229 }
230
231 if (['koboldcpp', 'vllm', 'llamacpp', 'ooba'].includes(request.body.api)) {
232 apiUrl = `${trimV1(request.body.server_url)}/v1/chat/completions`;
233 }
234
235 if (request.body.api === 'ooba') {
236 const imgMessage = body.messages.pop();
237 body.messages.push({
238 role: 'user',
239 content: imgMessage?.content?.[0]?.text,
240 });
241 body.messages.push({
242 role: 'user',
243 content: [],
244 image_url: imgMessage?.content?.[1]?.image_url?.url,
245 });
246 }
247
248 setAdditionalHeaders(request, { headers }, apiUrl);
249 console.debug('Multimodal captioning request', body);
250
251 const result = await fetch(apiUrl, {
252 method: 'POST',
253 headers: {
254 'Content-Type': 'application/json',
255 Authorization: `Bearer ${key}`,
256 ...headers,
257 },
258 body: JSON.stringify(body),
259 });
260
261 if (!result.ok) {
262 const text = await result.text();
263 console.warn('Multimodal captioning request failed', result.statusText, text);
264 return response.status(500).send(text);
265 }
266
267 /** @type {any} */
268 const data = await result.json();
269 console.info('Multimodal captioning response', data);
270 const caption = data?.choices?.[0]?.message?.content ?? data?.message?.content?.[0]?.text;
271
272 if (!caption) {
273 return response.status(500).send('No caption found');
274 }
275
276 return response.json({ caption });
277 } catch (error) {
278 console.error(error);
279 response.status(500).send('Internal server error');
280 }
281});
282
283router.post('/generate-voice', async (request, response) => {
284 try {
285 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
286
287 if (!key) {
288 console.warn('No OpenAI key found');
289 return response.sendStatus(400);
290 }
291
292 const requestBody = {
293 input: request.body.text,
294 response_format: 'mp3',
295 voice: request.body.voice ?? 'alloy',
296 speed: request.body.speed ?? 1,
297 model: request.body.model ?? 'tts-1',
298 };
299
300 if (request.body.instructions) {
301 requestBody.instructions = request.body.instructions;
302 }
303
304 console.debug('OpenAI TTS request', requestBody);
305
306 const result = await fetch('https://api.openai.com/v1/audio/speech', {
307 method: 'POST',
308 headers: {
309 'Content-Type': 'application/json',
310 Authorization: `Bearer ${key}`,
311 },
312 body: JSON.stringify(requestBody),
313 });
314
315 if (!result.ok) {
316 const text = await result.text();
317 console.warn('OpenAI request failed', result.statusText, text);
318 return response.status(500).send(text);
319 }
320
321 const buffer = await result.arrayBuffer();
322 response.setHeader('Content-Type', 'audio/mpeg');
323 return response.send(Buffer.from(buffer));
324 } catch (error) {
325 console.error('OpenAI TTS generation failed', error);
326 response.status(500).send('Internal server error');
327 }
328});
329
330// ElectronHub TTS proxy
331router.post('/electronhub/generate-voice', async (request, response) => {
332 try {
333 const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
334
335 if (!key) {
336 console.warn('No ElectronHub key found');
337 return response.sendStatus(400);
338 }
339
340 const requestBody = {
341 input: request.body.input,
342 voice: request.body.voice,
343 speed: request.body.speed ?? 1,
344 temperature: request.body.temperature ?? undefined,
345 model: request.body.model || 'tts-1',
346 response_format: 'mp3',
347 };
348
349 // Optional provider-specific params
350 if (request.body.instructions) requestBody.instructions = request.body.instructions;
351 if (request.body.speaker_transcript) requestBody.speaker_transcript = request.body.speaker_transcript;
352 if (Number.isFinite(request.body.cfg_scale)) requestBody.cfg_scale = Number(request.body.cfg_scale);
353 if (Number.isFinite(request.body.cfg_filter_top_k)) requestBody.cfg_filter_top_k = Number(request.body.cfg_filter_top_k);
354 if (Number.isFinite(request.body.speech_rate)) requestBody.speech_rate = Number(request.body.speech_rate);
355 if (Number.isFinite(request.body.pitch_adjustment)) requestBody.pitch_adjustment = Number(request.body.pitch_adjustment);
356 if (request.body.emotional_style) requestBody.emotional_style = request.body.emotional_style;
357
358 // Handle dynamic parameters sent from the frontend
359 const knownParams = new Set(Object.keys(requestBody));
360 for (const key in request.body) {
361 if (!knownParams.has(key) && request.body[key] !== undefined) {
362 requestBody[key] = request.body[key];
363 }
364 }
365
366 // Clean undefineds
367 Object.keys(requestBody).forEach(k => requestBody[k] === undefined && delete requestBody[k]);
368
369 console.debug('ElectronHub TTS request', requestBody);
370
371 const result = await fetch('https://api.electronhub.ai/v1/audio/speech', {
372 method: 'POST',
373 headers: {
374 'Content-Type': 'application/json',
375 Authorization: `Bearer ${key}`,
376 },
377 body: JSON.stringify(requestBody),
378 });
379
380 if (!result.ok) {
381 const text = await result.text();
382 console.warn('ElectronHub TTS request failed', result.statusText, text);
383 return response.status(500).send(text);
384 }
385
386 const contentType = result.headers.get('content-type') || 'audio/mpeg';
387 const buffer = await result.arrayBuffer();
388 response.setHeader('Content-Type', contentType);
389 return response.send(Buffer.from(buffer));
390 } catch (error) {
391 console.error('ElectronHub TTS generation failed', error);
392 response.status(500).send('Internal server error');
393 }
394});
395
396// ElectronHub model list
397router.post('/electronhub/models', async (request, response) => {
398 try {
399 const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
400
401 if (!key) {
402 console.warn('No ElectronHub key found');
403 return response.sendStatus(400);
404 }
405
406 const result = await fetch('https://api.electronhub.ai/v1/models', {
407 method: 'GET',
408 headers: {
409 Authorization: `Bearer ${key}`,
410 },
411 });
412
413 if (!result.ok) {
414 const text = await result.text();
415 console.warn('ElectronHub models request failed', result.statusText, text);
416 return response.status(500).send(text);
417 }
418 /** @type {any} */
419 const data = await result.json();
420 const models = data && Array.isArray(data.data) ? data.data : [];
421 return response.json(models);
422 } catch (error) {
423 console.error('ElectronHub models fetch failed', error);
424 response.status(500).send('Internal server error');
425 }
426});
427
428// Chutes TTS
429router.post('/chutes/generate-voice', async (request, response) => {
430 try {
431 const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
432
433 if (!key) {
434 console.warn('No Chutes key found');
435 return response.sendStatus(400);
436 }
437
438 const requestBody = {
439 text: request.body.input,
440 voice: request.body.voice || 'af_heart',
441 speed: request.body.speed || 1,
442 };
443
444 console.debug('Chutes TTS request', requestBody);
445
446 const result = await fetch('https://chutes-kokoro.chutes.ai/speak', {
447 method: 'POST',
448 headers: {
449 'Content-Type': 'application/json',
450 Authorization: `Bearer ${key}`,
451 },
452 body: JSON.stringify(requestBody),
453 });
454
455 if (!result.ok) {
456 const text = await result.text();
457 console.warn('Chutes TTS request failed', result.statusText, text);
458 return response.status(500).send(text);
459 }
460
461 const contentType = result.headers.get('content-type') || 'audio/mpeg';
462 const buffer = await result.arrayBuffer();
463 response.setHeader('Content-Type', contentType);
464 return response.send(Buffer.from(buffer));
465 } catch (error) {
466 console.error('Chutes TTS generation failed', error);
467 response.status(500).send('Internal server error');
468 }
469});
470
471router.post('/chutes/models/embedding', async (request, response) => {
472 try {
473 const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
474
475 if (!key) {
476 console.warn('No Chutes key found');
477 return response.sendStatus(400);
478 }
479
480 const result = await fetch('https://api.chutes.ai/chutes/?template=embedding&include_public=true&limit=999', {
481 method: 'GET',
482 headers: {
483 Authorization: `Bearer ${key}`,
484 },
485 });
486
487 if (!result.ok) {
488 const text = await result.text();
489 console.warn('Chutes embedding models request failed', result.statusText, text);
490 return response.status(500).send(text);
491 }
492
493 /** @type {any} */
494 const data = await result.json();
495
496 if (!Array.isArray(data?.items)) {
497 console.warn('Chutes embedding models response invalid', data);
498 return response.sendStatus(500);
499 }
500 return response.json(data.items);
501 } catch (error) {
502 console.error('Chutes embedding models fetch failed', error);
503 response.sendStatus(500);
504 }
505});
506
507router.post('/nanogpt/models/embedding', async (request, response) => {
508 try {
509 const key = readSecret(request.user.directories, SECRET_KEYS.NANOGPT);
510
511 if (!key) {
512 console.warn('No NanoGPT key found');
513 return response.sendStatus(400);
514 }
515
516 const result = await fetch('https://nano-gpt.com/api/v1/embedding-models', {
517 method: 'GET',
518 headers: {
519 'Authorization': `Bearer ${key}`,
520 'Accept-Encoding': 'identity',
521 },
522 });
523
524 if (!result.ok) {
525 const text = await result.text();
526 console.warn('NanoGPT embedding models request failed', result.statusText, text);
527 return response.status(500).send(text);
528 }
529
530 /** @type {any} */
531 const data = await result.json();
532
533 if (!Array.isArray(data?.data)) {
534 console.warn('NanoGPT embedding models response invalid', data);
535 return response.sendStatus(500);
536 }
537 return response.json(data.data);
538 } catch (error) {
539 console.error('NanoGPT embedding models fetch failed', error);
540 response.sendStatus(500);
541 }
542});
543
544router.post('/siliconflow/models/embedding', async (request, response) => {
545 try {
546 const key = readSecret(request.user.directories, SECRET_KEYS.SILICONFLOW);
547
548 if (!key) {
549 console.warn('No SiliconFlow key found');
550 return response.sendStatus(400);
551 }
552
553 const apiUrl = request.body.siliconflow_endpoint === SILICONFLOW_ENDPOINT.CN
554 ? 'https://api.siliconflow.cn/v1/models?type=text&sub_type=embedding'
555 : 'https://api.siliconflow.com/v1/models?type=text&sub_type=embedding';
556
557 const result = await fetch(apiUrl, {
558 method: 'GET',
559 headers: {
560 Authorization: `Bearer ${key}`,
561 },
562 });
563
564 if (!result.ok) {
565 const text = await result.text();
566 console.warn('SiliconFlow embedding models request failed', result.statusText, text);
567 return response.status(500).send(text);
568 }
569
570 /** @type {any} */
571 const data = await result.json();
572
573 if (!Array.isArray(data?.data)) {
574 console.warn('SiliconFlow embedding models response invalid', data);
575 return response.sendStatus(500);
576 }
577
578 return response.json(data.data);
579 } catch (error) {
580 console.error('SiliconFlow embedding models fetch failed', error);
581 response.sendStatus(500);
582 }
583});
584
585router.post('/workers-ai/models/embedding', async (request, response) => {
586 try {
587 const key = readSecret(request.user.directories, SECRET_KEYS.WORKERS_AI);
588
589 if (!key) {
590 console.warn('No Workers AI key found');
591 return response.sendStatus(400);
592 }
593
594 const accountId = String(request.body.workers_ai_account_id || '').trim();
595 if (!accountId) {
596 console.warn('No Workers AI account ID found');
597 return response.sendStatus(400);
598 }
599
600 const apiUrl = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/models/search?task=Text+Embeddings&per_page=100`;
601 const result = await fetch(apiUrl, {
602 method: 'GET',
603 headers: {
604 Authorization: `Bearer ${key}`,
605 },
606 });
607
608 if (!result.ok) {
609 const text = await result.text();
610 console.warn('Workers AI embedding models request failed', result.statusText, text);
611 return response.status(500).send(text);
612 }
613
614 /** @type {any} */
615 const data = await result.json();
616
617 if (!Array.isArray(data?.result)) {
618 console.warn('Workers AI embedding models response invalid', data);
619 return response.sendStatus(500);
620 }
621
622 return response.json(data.result.map(m => ({ ...m, id: m.name })));
623 } catch (error) {
624 console.error('Workers AI embedding models fetch failed', error);
625 response.sendStatus(500);
626 }
627});
628
629router.post('/generate-image', async (request, response) => {
630 try {
631 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
632
633 if (!key) {
634 console.warn('No OpenAI key found');
635 return response.sendStatus(400);
636 }
637
638 console.debug('OpenAI request', request.body);
639
640 const result = await fetch('https://api.openai.com/v1/images/generations', {
641 method: 'POST',
642 headers: {
643 'Content-Type': 'application/json',
644 Authorization: `Bearer ${key}`,
645 },
646 body: JSON.stringify(request.body),
647 });
648
649 if (!result.ok) {
650 const text = await result.text();
651 console.warn('OpenAI request failed', result.statusText, text);
652 return response.status(500).send(text);
653 }
654
655 const data = await result.json();
656 return response.json(data);
657 } catch (error) {
658 console.error(error);
659 response.status(500).send('Internal server error');
660 }
661});
662
663router.post('/generate-video', async (request, response) => {
664 try {
665 const controller = new AbortController();
666 request.socket.removeAllListeners('close');
667 request.socket.on('close', function () {
668 controller.abort();
669 });
670
671 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);
672
673 if (!key) {
674 console.warn('No OpenAI key found');
675 return response.sendStatus(400);
676 }
677
678 console.debug('OpenAI video generation request', request.body);
679
680 const videoJobResponse = await fetch('https://api.openai.com/v1/videos', {
681 method: 'POST',
682 headers: {
683 'Content-Type': 'application/json',
684 'Authorization': `Bearer ${key}`,
685 },
686 body: JSON.stringify({
687 prompt: request.body.prompt,
688 model: request.body.model || 'sora-2',
689 size: request.body.size || '720x1280',
690 seconds: request.body.seconds || '8',
691 }),
692 });
693
694 if (!videoJobResponse.ok) {
695 const text = await videoJobResponse.text();
696 console.warn('OpenAI video generation request failed', videoJobResponse.statusText, text);
697 return response.status(500).send(text);
698 }
699
700 /** @type {any} */
701 const videoJob = await videoJobResponse.json();
702
703 if (!videoJob || !videoJob.id) {
704 console.warn('OpenAI video generation returned no job ID', videoJob);
705 return response.status(500).send('No video job ID returned');
706 }
707
708 // Poll for video generation completion
709 for (let attempt = 0; attempt < 30; attempt++) {
710 if (controller.signal.aborted) {
711 console.info('OpenAI video generation aborted by client');
712 return response.status(500).send('Video generation aborted by client');
713 }
714
715 await delay(5000 + attempt * 1000);
716 console.debug(`Polling OpenAI video job ${videoJob.id}, attempt ${attempt + 1}`);
717
718 const pollResponse = await fetch(`https://api.openai.com/v1/videos/${videoJob.id}`, {
719 method: 'GET',
720 headers: {
721 'Authorization': `Bearer ${key}`,
722 },
723 });
724
725 if (!pollResponse.ok) {
726 const text = await pollResponse.text();
727 console.warn('OpenAI video job polling failed', pollResponse.statusText, text);
728 return response.status(500).send(text);
729 }
730
731 /** @type {any} */
732 const pollResult = await pollResponse.json();
733 console.debug(`OpenAI video job status: ${pollResult.status}, progress: ${pollResult.progress}`);
734
735 if (pollResult.status === 'failed') {
736 console.warn('OpenAI video generation failed', pollResult);
737 return response.status(500).send('Video generation failed');
738 }
739
740 if (pollResult.status === 'completed') {
741 const contentResponse = await fetch(`https://api.openai.com/v1/videos/${videoJob.id}/content`, {
742 method: 'GET',
743 headers: {
744 'Authorization': `Bearer ${key}`,
745 },
746 });
747
748 if (!contentResponse.ok) {
749 const text = await contentResponse.text();
750 console.warn('OpenAI video content fetch failed', contentResponse.statusText, text);
751 return response.status(500).send(text);
752 }
753
754 const contentBuffer = await contentResponse.arrayBuffer();
755 return response.send({ format: 'mp4', data: Buffer.from(contentBuffer).toString('base64') });
756 }
757 }
758 } catch (error) {
759 console.error('OpenAI video generation failed', error);
760 response.status(500).send('Internal server error');
761 }
762});
763
764const custom = express.Router();
765
766custom.post('/generate-voice', async (request, response) => {
767 try {
768 const key = readSecret(request.user.directories, SECRET_KEYS.CUSTOM_OPENAI_TTS);
769 const { input, provider_endpoint, response_format, voice, speed, model } = request.body;
770
771 if (!provider_endpoint) {
772 console.warn('No OpenAI-compatible TTS provider endpoint provided');
773 return response.sendStatus(400);
774 }
775
776 const result = await fetch(provider_endpoint, {
777 method: 'POST',
778 headers: {
779 'Content-Type': 'application/json',
780 Authorization: `Bearer ${key ?? ''}`,
781 },
782 body: JSON.stringify({
783 input: input ?? '',
784 response_format: response_format ?? 'mp3',
785 voice: voice ?? 'alloy',
786 speed: speed ?? 1,
787 model: model ?? 'tts-1',
788 }),
789 });
790
791 if (!result.ok) {
792 const text = await result.text();
793 console.warn('OpenAI request failed', result.statusText, text);
794 return response.status(500).send(text);
795 }
796
797 const buffer = await result.arrayBuffer();
798 response.setHeader('Content-Type', 'audio/mpeg');
799 return response.send(Buffer.from(buffer));
800 } catch (error) {
801 console.error('OpenAI TTS generation failed', error);
802 response.status(500).send('Internal server error');
803 }
804});
805
806router.use('/custom', custom);
807
808/**
809 * Creates a transcribe-audio endpoint handler for a given provider.
810 * @param {object} config - Provider configuration
811 * @param {string} config.secretKey - The SECRET_KEYS enum value for the provider
812 * @param {string} config.apiUrl - The transcription API endpoint URL
813 * @param {string} config.providerName - Display name for logging
814 * @returns {import('express').RequestHandler} Express request handler
815 */
816function createTranscribeHandler({ secretKey, apiUrl, providerName }) {
817 return async (request, response) => {
818 try {
819 const key = readSecret(request.user.directories, secretKey);
820
821 if (!key) {
822 console.warn(`No ${providerName} key found`);
823 return response.sendStatus(400);
824 }
825
826 if (!request.file) {
827 console.warn('No audio file found');
828 return response.sendStatus(400);
829 }
830
831 console.info(`Processing audio file with ${providerName}`, request.file.path);
832 const formData = new FormData();
833 formData.append('file', fs.createReadStream(request.file.path), { filename: 'audio.wav', contentType: 'audio/wav' });
834 formData.append('model', request.body.model);
835
836 if (request.body.language) {
837 formData.append('language', request.body.language);
838 }
839
840 const result = await fetch(apiUrl, {
841 method: 'POST',
842 headers: {
843 'Authorization': `Bearer ${key}`,
844 ...formData.getHeaders(),
845 },
846 body: formData,
847 });
848
849 if (!result.ok) {
850 const text = await result.text();
851 console.warn(`${providerName} request failed`, result.statusText, text);
852 return response.status(500).send(text);
853 }
854
855 fs.unlinkSync(request.file.path);
856 const data = await result.json();
857 console.debug(`${providerName} transcription response`, data);
858 return response.json(data);
859 } catch (error) {
860 console.error(`${providerName} transcription failed`, error);
861 response.status(500).send('Internal server error');
862 }
863 };
864}
865
866router.post('/transcribe-audio', createTranscribeHandler({
867 secretKey: SECRET_KEYS.OPENAI,
868 apiUrl: 'https://api.openai.com/v1/audio/transcriptions',
869 providerName: 'OpenAI',
870}));
871
872router.post('/groq/transcribe-audio', createTranscribeHandler({
873 secretKey: SECRET_KEYS.GROQ,
874 apiUrl: 'https://api.groq.com/openai/v1/audio/transcriptions',
875 providerName: 'Groq',
876}));
877
878router.post('/mistral/transcribe-audio', createTranscribeHandler({
879 secretKey: SECRET_KEYS.MISTRALAI,
880 apiUrl: 'https://api.mistral.ai/v1/audio/transcriptions',
881 providerName: 'MistralAI',
882}));
883
884router.post('/zai/transcribe-audio', createTranscribeHandler({
885 secretKey: SECRET_KEYS.ZAI,
886 apiUrl: 'https://api.z.ai/api/paas/v4/audio/transcriptions',
887 providerName: 'Z.AI',
888}));
889
890router.post('/chutes/transcribe-audio', async (request, response) => {
891 try {
892 const key = readSecret(request.user.directories, SECRET_KEYS.CHUTES);
893
894 if (!key) {
895 console.warn('No Chutes key found');
896 return response.sendStatus(400);
897 }
898
899 if (!request.file) {
900 console.warn('No audio file found');
901 return response.sendStatus(400);
902 }
903
904 console.info('Processing audio file with Chutes', request.file.path);
905 const audioBase64 = fs.readFileSync(request.file.path).toString('base64');
906
907 const result = await fetch(`https://${request.body.model}.chutes.ai/transcribe`, {
908 method: 'POST',
909 headers: {
910 'Authorization': `Bearer ${key}`,
911 'Content-Type': 'application/json',
912 },
913 body: JSON.stringify({
914 audio_b64: audioBase64,
915 }),
916 });
917
918 if (!result.ok) {
919 const text = await result.text();
920 console.warn('Chutes request failed', result.statusText, text);
921 return response.status(500).send(text);
922 }
923
924 fs.unlinkSync(request.file.path);
925 const data = await result.json();
926 console.debug('Chutes transcription response', data);
927
928 if (!Array.isArray(data)) {
929 console.warn('Chutes transcription response invalid', data);
930 return response.sendStatus(500);
931 }
932
933 const fullText = data.map(chunk => chunk.text || '').join('').trim();
934 return response.json({ text: fullText });
935 } catch (error) {
936 console.error('Chutes transcription failed', error);
937 response.status(500).send('Internal server error');
938 }
939});