qwen-api-proxy 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +49 -0
- package/LICENSE +21 -0
- package/README.md +2054 -0
- package/bin/qwen-api-proxy.js +414 -0
- package/index.js +444 -0
- package/package.json +85 -0
- package/src/Authorization.txt +17 -0
- package/src/AvailableModels.txt +26 -0
- package/src/api/chat.js +1392 -0
- package/src/api/chatHistory.js +344 -0
- package/src/api/fileUpload.js +182 -0
- package/src/api/imageGeneration.js +459 -0
- package/src/api/modelMapping.js +274 -0
- package/src/api/routes.js +2160 -0
- package/src/api/tokenManager.js +382 -0
- package/src/browser/auth.js +171 -0
- package/src/browser/browser.js +233 -0
- package/src/browser/session.js +134 -0
- package/src/config.js +116 -0
- package/src/logger/index.js +89 -0
- package/src/utils/accountSetup.js +153 -0
- package/src/utils/botSettings.js +231 -0
- package/src/utils/permissionChecker.js +205 -0
- package/src/utils/prompt.js +11 -0
- package/src/utils/proxy.js +255 -0
- package/src/utils/telegramBot.js +2977 -0
- package/src/utils/telegramNotifier.js +94 -0
|
@@ -0,0 +1,2160 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { sendMessage, getAllModels, getApiKeys, createChatV2, pollTaskStatus, pagePool, extractAuthToken } from './chat.js';
|
|
3
|
+
import { getAuthenticationStatus, getBrowserContext } from '../browser/browser.js';
|
|
4
|
+
import { checkAuthentication } from '../browser/auth.js';
|
|
5
|
+
import { logInfo, logError, logDebug } from '../logger/index.js';
|
|
6
|
+
import { getMappedModel } from './modelMapping.js';
|
|
7
|
+
import { getStsToken, uploadFileToQwen } from './fileUpload.js';
|
|
8
|
+
import { loadHistory, saveHistory } from './chatHistory.js';
|
|
9
|
+
import { generateImage, getAvailableImageModels, checkImageApiAvailability } from './imageGeneration.js';
|
|
10
|
+
import { MAX_FILE_SIZE, UPLOADS_DIR, STREAMING_CHUNK_DELAY, ALLOW_UNSCOPED_SESSION_CHAT_RESTORE, IMAGE_GENERATION_MODE, DASHSCOPE_API_KEY, FORCE_NEW_CHAT_PER_REQUEST } from '../config.js';
|
|
11
|
+
import { getActiveModel } from '../utils/botSettings.js';
|
|
12
|
+
import { getFileDownloadProxyAgent } from '../utils/proxy.js';
|
|
13
|
+
import multer from 'multer';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import crypto from 'crypto';
|
|
17
|
+
import { listTokens, markInvalid, markRateLimited, markValid } from './tokenManager.js';
|
|
18
|
+
|
|
19
|
+
// ─── Helpers: File processing ────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Обрабатывает файлы из разных форматов и возвращает формат для Qwen API
|
|
23
|
+
* Поддерживает:
|
|
24
|
+
* 1. Base64 data URLs (data:image/jpeg;base64,...)
|
|
25
|
+
* 2. HTTP/HTTPS URLs (скачивает и загружает в OSS)
|
|
26
|
+
* 3. Локальные файлы (загруженные через multer)
|
|
27
|
+
*
|
|
28
|
+
* Возвращает массив в формате: [{type: 'image', image: 'url'}] или [{type: 'file', file: 'url'}]
|
|
29
|
+
*/
|
|
30
|
+
async function processFilesForQwen(files) {
|
|
31
|
+
if (!files || !Array.isArray(files) || files.length === 0) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const qwenFiles = [];
|
|
36
|
+
const tempFiles = [];
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
let fileUrl = null;
|
|
41
|
+
|
|
42
|
+
// Формат 1: {url: 'data:image/...;base64,...'} или {url: 'http://...'}
|
|
43
|
+
if (file.url) {
|
|
44
|
+
// Если это base64 data URL - сохраняем во временный файл и загружаем в OSS
|
|
45
|
+
if (file.url.startsWith('data:')) {
|
|
46
|
+
const [header, base64Data] = file.url.split(',');
|
|
47
|
+
const mimeType = header.match(/data:([^;]+)/)?.[1] || 'application/octet-stream';
|
|
48
|
+
const ext = mimeType.split('/')[1]?.split('+')[0] || 'bin';
|
|
49
|
+
|
|
50
|
+
const tempFileName = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${ext}`;
|
|
51
|
+
const tempFilePath = path.join(UPLOADS_DIR, tempFileName);
|
|
52
|
+
|
|
53
|
+
fs.writeFileSync(tempFilePath, Buffer.from(base64Data, 'base64'));
|
|
54
|
+
tempFiles.push(tempFilePath);
|
|
55
|
+
|
|
56
|
+
logInfo(`📁 Base64 файл сохранен: ${tempFilePath}`);
|
|
57
|
+
|
|
58
|
+
// Загружаем в OSS
|
|
59
|
+
const uploadResult = await uploadFileToQwen(tempFilePath);
|
|
60
|
+
if (uploadResult.success) {
|
|
61
|
+
fileUrl = uploadResult.url;
|
|
62
|
+
logInfo(`✅ Base64 файл загружен в OSS: ${uploadResult.url}`);
|
|
63
|
+
} else {
|
|
64
|
+
logError(`❌ Ошибка загрузки base64 файла: ${uploadResult.error}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
} else if (file.url.startsWith('http://') || file.url.startsWith('https://')) {
|
|
68
|
+
// HTTP/HTTPS URL - скачиваем файл и загружаем в OSS
|
|
69
|
+
logInfo(`📥 Скачивание файла: ${file.url}`);
|
|
70
|
+
logInfo(`📥 Хост файла: ${new URL(file.url).hostname}`);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Получаем прокси агент если настроен
|
|
74
|
+
const downloadProxyAgent = getFileDownloadProxyAgent();
|
|
75
|
+
|
|
76
|
+
let response;
|
|
77
|
+
|
|
78
|
+
if (downloadProxyAgent) {
|
|
79
|
+
// Используем node-fetch с прокси
|
|
80
|
+
const { default: nodeFetch } = await import('node-fetch');
|
|
81
|
+
logInfo(`🔗 Используем прокси для скачивания: ${file.url}`);
|
|
82
|
+
response = await nodeFetch(file.url, {
|
|
83
|
+
agent: downloadProxyAgent,
|
|
84
|
+
redirect: 'follow',
|
|
85
|
+
timeout: 30000
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
// Используем нативный fetch без прокси
|
|
89
|
+
logWarn(`⚠️ Прокси не настроен, скачиваем напрямую: ${file.url}`);
|
|
90
|
+
response = await fetch(file.url, {
|
|
91
|
+
redirect: 'follow'
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const errorBody = await response.text().catch(() => '');
|
|
97
|
+
logError(`❌ Ошибка скачивания: ${response.status} ${response.statusText}${errorBody ? ` | Ответ: ${errorBody.substring(0, 500)}` : ''}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Определяем тип файла из Content-Type или расширения URL
|
|
102
|
+
const contentType = response.headers.get('content-type') || '';
|
|
103
|
+
const urlExt = file.url.split('.').pop()?.split('?')[0] || '';
|
|
104
|
+
let ext = 'bin';
|
|
105
|
+
|
|
106
|
+
if (contentType.includes('image/jpeg')) ext = 'jpg';
|
|
107
|
+
else if (contentType.includes('image/png')) ext = 'png';
|
|
108
|
+
else if (contentType.includes('image/gif')) ext = 'gif';
|
|
109
|
+
else if (contentType.includes('image/webp')) ext = 'webp';
|
|
110
|
+
else if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'pdf', 'txt', 'doc', 'docx'].includes(urlExt)) {
|
|
111
|
+
ext = urlExt;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Скачиваем во временный файл
|
|
115
|
+
const tempFileName = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${ext}`;
|
|
116
|
+
const tempFilePath = path.join(UPLOADS_DIR, tempFileName);
|
|
117
|
+
|
|
118
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
119
|
+
fs.writeFileSync(tempFilePath, buffer);
|
|
120
|
+
tempFiles.push(tempFilePath);
|
|
121
|
+
|
|
122
|
+
logInfo(`📁 Файл скачан: ${tempFilePath} (${buffer.length} байт)`);
|
|
123
|
+
|
|
124
|
+
// Загружаем в OSS
|
|
125
|
+
const uploadResult = await uploadFileToQwen(tempFilePath);
|
|
126
|
+
if (uploadResult.success) {
|
|
127
|
+
fileUrl = uploadResult.url;
|
|
128
|
+
logInfo(`✅ HTTP файл загружен в OSS: ${uploadResult.url}`);
|
|
129
|
+
} else {
|
|
130
|
+
logError(`❌ Ошибка загрузки HTTP файла: ${uploadResult.error}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logError(`❌ Ошибка при скачивании файла`, error);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// Другие протоколы - передаем напрямую
|
|
139
|
+
fileUrl = file.url;
|
|
140
|
+
logInfo(`📁 URL добавлен: ${file.url}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Формат 2: Multer file object
|
|
144
|
+
else if (file.path) {
|
|
145
|
+
const uploadResult = await uploadFileToQwen(file.path);
|
|
146
|
+
if (uploadResult.success) {
|
|
147
|
+
fileUrl = uploadResult.url;
|
|
148
|
+
logInfo(`✅ File uploaded to OSS: ${uploadResult.url}`);
|
|
149
|
+
} else {
|
|
150
|
+
logError(`❌ Ошибка загрузки файла: ${uploadResult.error}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Определяем тип файла и добавляем в правильном формате
|
|
156
|
+
if (fileUrl) {
|
|
157
|
+
const isImage = fileUrl.match(/\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i);
|
|
158
|
+
if (isImage) {
|
|
159
|
+
qwenFiles.push({type: 'image', image: fileUrl});
|
|
160
|
+
} else {
|
|
161
|
+
qwenFiles.push({type: 'file', file: fileUrl});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Очищаем временные файлы
|
|
167
|
+
for (const tempFile of tempFiles) {
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(tempFile);
|
|
170
|
+
logDebug(`🗑️ Временный файл удален: ${tempFile}`);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
logDebug(`Не удалось удалить временный файл: ${e.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return qwenFiles;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
logError('Ошибка при обработке файлов', error);
|
|
179
|
+
|
|
180
|
+
// Очищаем временные файлы при ошибке
|
|
181
|
+
for (const tempFile of tempFiles) {
|
|
182
|
+
try {
|
|
183
|
+
fs.unlinkSync(tempFile);
|
|
184
|
+
} catch (e) { /* ignore */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Функция для генерирования детерминированного chatId на основе истории
|
|
192
|
+
function generateChatIdFromHistory(messages) {
|
|
193
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Фильтруем служебные сообщения Open WebUI
|
|
198
|
+
// Игнорируем сообщения, которые начинаются с "### Task:" или "History:"
|
|
199
|
+
const realMessages = messages.filter(m => {
|
|
200
|
+
if (m.role !== 'user') return true;
|
|
201
|
+
const content = typeof m.content === 'string' ? m.content : '';
|
|
202
|
+
return !content.startsWith('### Task:') && !content.startsWith('History:');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Если остались только служебные сообщения, используем исходные
|
|
206
|
+
const messagesToUse = realMessages.length > 0 ? realMessages : messages;
|
|
207
|
+
|
|
208
|
+
// Используем хеш первого реального сообщения пользователя для создания стабильного ID
|
|
209
|
+
const userMessages = messagesToUse
|
|
210
|
+
.filter(m => m.role === 'user')
|
|
211
|
+
.slice(0, 1) // Берём первое сообщение пользователя
|
|
212
|
+
.map(m => typeof m.content === 'string' ? m.content : JSON.stringify(m.content))
|
|
213
|
+
.join('||');
|
|
214
|
+
|
|
215
|
+
if (!userMessages) return null;
|
|
216
|
+
|
|
217
|
+
// Создаём хеш для детерминированного ID
|
|
218
|
+
const hash = crypto
|
|
219
|
+
.createHash('sha256')
|
|
220
|
+
.update(userMessages)
|
|
221
|
+
.digest('hex')
|
|
222
|
+
.substring(0, 16);
|
|
223
|
+
|
|
224
|
+
return `chat_${hash}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeIdValue(value) {
|
|
228
|
+
if (value === null || value === undefined) return null;
|
|
229
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
|
|
230
|
+
if (typeof value !== 'string') return null;
|
|
231
|
+
|
|
232
|
+
const trimmed = value.trim();
|
|
233
|
+
if (!trimmed) return null;
|
|
234
|
+
|
|
235
|
+
const lower = trimmed.toLowerCase();
|
|
236
|
+
if (lower === 'null' || lower === 'undefined') return null;
|
|
237
|
+
|
|
238
|
+
return trimmed;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function pickFirstId(candidates) {
|
|
242
|
+
for (const candidate of candidates) {
|
|
243
|
+
const normalized = normalizeIdValue(candidate);
|
|
244
|
+
if (normalized) return normalized;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildInternalChatIdFromHint(hint) {
|
|
250
|
+
const normalizedHint = normalizeIdValue(hint);
|
|
251
|
+
if (!normalizedHint) return null;
|
|
252
|
+
|
|
253
|
+
const hash = crypto
|
|
254
|
+
.createHash('sha256')
|
|
255
|
+
.update(`client-conversation:${normalizedHint}`)
|
|
256
|
+
.digest('hex')
|
|
257
|
+
.substring(0, 16);
|
|
258
|
+
|
|
259
|
+
return `chat_${hash}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function extractConversationHint(req) {
|
|
263
|
+
const body = req.body || {};
|
|
264
|
+
const metadata = body && typeof body.metadata === 'object' ? body.metadata : {};
|
|
265
|
+
|
|
266
|
+
return pickFirstId([
|
|
267
|
+
body.conversation_id,
|
|
268
|
+
body.conversationId,
|
|
269
|
+
body.chat_id,
|
|
270
|
+
metadata.conversation_id,
|
|
271
|
+
metadata.conversationId,
|
|
272
|
+
metadata.chat_id,
|
|
273
|
+
metadata.chatId,
|
|
274
|
+
req.get?.('x-conversation-id'),
|
|
275
|
+
req.get?.('x-openwebui-conversation-id'),
|
|
276
|
+
req.get?.('x-chat-id'),
|
|
277
|
+
req.get?.('x-openwebui-chat-id')
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function extractParentHint(req) {
|
|
282
|
+
const body = req.body || {};
|
|
283
|
+
const metadata = body && typeof body.metadata === 'object' ? body.metadata : {};
|
|
284
|
+
|
|
285
|
+
return pickFirstId([
|
|
286
|
+
body.parentId,
|
|
287
|
+
body.parent_id,
|
|
288
|
+
body.x_qwen_parent_id,
|
|
289
|
+
body.response_id,
|
|
290
|
+
metadata.parentId,
|
|
291
|
+
metadata.parent_id,
|
|
292
|
+
metadata.response_id,
|
|
293
|
+
req.get?.('x-parent-id'),
|
|
294
|
+
req.get?.('x-openwebui-parent-id')
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function isTruthyFlag(value) {
|
|
299
|
+
if (typeof value === 'boolean') return value;
|
|
300
|
+
if (typeof value === 'number') return value === 1;
|
|
301
|
+
if (typeof value !== 'string') return false;
|
|
302
|
+
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function shouldForceNewChat(req) {
|
|
306
|
+
const body = req.body || {};
|
|
307
|
+
|
|
308
|
+
// Если включен режим FORCE_NEW_CHAT_PER_REQUEST - всегда создаем новый чат
|
|
309
|
+
if (FORCE_NEW_CHAT_PER_REQUEST) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return [
|
|
314
|
+
body.newChat,
|
|
315
|
+
body.new_chat,
|
|
316
|
+
body.resetChat,
|
|
317
|
+
body.reset_chat,
|
|
318
|
+
req.get?.('x-new-chat'),
|
|
319
|
+
req.get?.('x-reset-chat')
|
|
320
|
+
].some(isTruthyFlag);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function shouldPersistSessionContext(scope = null) {
|
|
324
|
+
const normalizedScope = normalizeIdValue(scope);
|
|
325
|
+
return Boolean(normalizedScope) || ALLOW_UNSCOPED_SESSION_CHAT_RESTORE;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Глобальное хранилище для маппинга между сгенерированными ID и реальными Qwen chatId
|
|
329
|
+
const chatIdMap = new Map();
|
|
330
|
+
|
|
331
|
+
function mapChatId(generatedId, qwenChatId) {
|
|
332
|
+
if (generatedId) {
|
|
333
|
+
chatIdMap.set(generatedId, qwenChatId);
|
|
334
|
+
logDebug(`Маппинг чата: ${generatedId} -> ${qwenChatId}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function getChatIdFromMap(generatedId) {
|
|
339
|
+
return generatedId ? chatIdMap.get(generatedId) : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function resolveQwenChatId(effectiveChatId, mappedModel) {
|
|
343
|
+
let qwenChatId = effectiveChatId;
|
|
344
|
+
const mapped = getChatIdFromMap(effectiveChatId);
|
|
345
|
+
|
|
346
|
+
if (mapped) {
|
|
347
|
+
qwenChatId = mapped;
|
|
348
|
+
logInfo(`🔁 Используется сопоставленный Qwen chatId: ${qwenChatId} (from ${effectiveChatId})`);
|
|
349
|
+
return qwenChatId;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (effectiveChatId && effectiveChatId.startsWith('chat_')) {
|
|
353
|
+
try {
|
|
354
|
+
const created = await createChatV2(mappedModel, 'Сессия OpenWebUI');
|
|
355
|
+
if (created && created.chatId) {
|
|
356
|
+
mapChatId(effectiveChatId, created.chatId);
|
|
357
|
+
qwenChatId = created.chatId;
|
|
358
|
+
logInfo(`🔨 Создан Qwen chat ${qwenChatId} и привязан к ${effectiveChatId}`);
|
|
359
|
+
}
|
|
360
|
+
} catch (error) {
|
|
361
|
+
logDebug(`Не удалось создать Qwen chat для ${effectiveChatId}: ${error.message}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return qwenChatId;
|
|
366
|
+
}
|
|
367
|
+
import { testToken } from './chat.js';
|
|
368
|
+
|
|
369
|
+
function isOpenWebUiMetaRequest(messages) {
|
|
370
|
+
if (!Array.isArray(messages) || messages.length === 0) return false;
|
|
371
|
+
const lastUserMessage = messages.filter(m => m && m.role === 'user').pop();
|
|
372
|
+
if (!lastUserMessage) return false;
|
|
373
|
+
|
|
374
|
+
const content = lastUserMessage.content;
|
|
375
|
+
if (Array.isArray(content)) return false; // multimodal / normal user message
|
|
376
|
+
if (typeof content !== 'string') return false;
|
|
377
|
+
|
|
378
|
+
const text = content.trimStart();
|
|
379
|
+
|
|
380
|
+
// OpenWebUI background/meta prompts that should not reuse the main chatId/session.
|
|
381
|
+
if (text.startsWith('### Task:')) return true;
|
|
382
|
+
if (text.startsWith('History:')) return true;
|
|
383
|
+
|
|
384
|
+
// Some variants embed history blocks and task instructions.
|
|
385
|
+
if (text.includes('<chat_history>') && text.includes('### Task:')) return true;
|
|
386
|
+
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ============================================
|
|
391
|
+
// СЕССИОННАЯ СИСТЕМА ДЛЯ ОТСЛЕЖИВАНИЯ ЧАТОВ
|
|
392
|
+
// ============================================
|
|
393
|
+
// Scoped-сессии (по conversation_id/chat_id) включены всегда.
|
|
394
|
+
// Unscoped fallback по IP + User-Agent работает только в legacy-режиме
|
|
395
|
+
// через ALLOW_UNSCOPED_SESSION_CHAT_RESTORE=true.
|
|
396
|
+
const sessionToChatMap = new Map(); // session-key -> {chatId, parentId, timestamp}
|
|
397
|
+
|
|
398
|
+
function getSessionKey(req) {
|
|
399
|
+
// Создаём уникальный ключ сессии на основе IP и User-Agent
|
|
400
|
+
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
|
401
|
+
const userAgent = req.get('user-agent') || 'unknown';
|
|
402
|
+
return crypto.createHash('sha256').update(`${ip}||${userAgent}`).digest('hex');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getScopedSessionKey(req, scope = null) {
|
|
406
|
+
const baseKey = getSessionKey(req);
|
|
407
|
+
const normalizedScope = normalizeIdValue(scope);
|
|
408
|
+
return normalizedScope ? `${baseKey}::${normalizedScope}` : baseKey;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getSavedChatId(req, scope = null) {
|
|
412
|
+
const keysToTry = [getScopedSessionKey(req, scope)];
|
|
413
|
+
|
|
414
|
+
for (const sessionKey of keysToTry) {
|
|
415
|
+
const sessionData = sessionToChatMap.get(sessionKey);
|
|
416
|
+
if (sessionData && (Date.now() - sessionData.timestamp) < 3600000) { // 1 hour
|
|
417
|
+
return sessionData;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
function saveChatIdForSession(req, chatId, parentId, scope = null) {
|
|
424
|
+
const sessionKey = getScopedSessionKey(req, scope);
|
|
425
|
+
const normalizedScope = normalizeIdValue(scope);
|
|
426
|
+
|
|
427
|
+
sessionToChatMap.set(sessionKey, {
|
|
428
|
+
chatId,
|
|
429
|
+
parentId,
|
|
430
|
+
scope: normalizedScope,
|
|
431
|
+
timestamp: Date.now()
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const scopeSuffix = normalizedScope ? ` (scope=${normalizedScope})` : "";
|
|
435
|
+
logDebug(`Saved chatId ${chatId} for session ${sessionKey.substring(0, 8)}${scopeSuffix}`);
|
|
436
|
+
}
|
|
437
|
+
// Очистка старых сессий каждые 10 минут
|
|
438
|
+
setInterval(() => {
|
|
439
|
+
const now = Date.now();
|
|
440
|
+
const oneHourAgo = now - 3600000;
|
|
441
|
+
let cleaned = 0;
|
|
442
|
+
for (const [key, value] of sessionToChatMap.entries()) {
|
|
443
|
+
if (value.timestamp < oneHourAgo) {
|
|
444
|
+
sessionToChatMap.delete(key);
|
|
445
|
+
cleaned++;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (cleaned > 0) {
|
|
449
|
+
logDebug(`Очищено ${cleaned} старых сессий`);
|
|
450
|
+
}
|
|
451
|
+
}, 600000); // 10 минут
|
|
452
|
+
|
|
453
|
+
const router = express.Router();
|
|
454
|
+
|
|
455
|
+
// ─── Multer для загрузки файлов ──────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
const storage = multer.diskStorage({
|
|
458
|
+
destination(req, file, cb) {
|
|
459
|
+
const uploadDir = path.join(process.cwd(), UPLOADS_DIR);
|
|
460
|
+
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
461
|
+
cb(null, uploadDir);
|
|
462
|
+
},
|
|
463
|
+
filename(req, file, cb) {
|
|
464
|
+
cb(null, Date.now() + '-' + crypto.randomBytes(8).toString('hex') + '-' + file.originalname);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const upload = multer({ storage, limits: { fileSize: MAX_FILE_SIZE } });
|
|
469
|
+
|
|
470
|
+
// ─── Auth middleware ─────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
function authMiddleware(req, res, next) {
|
|
473
|
+
const apiKeys = getApiKeys();
|
|
474
|
+
if (apiKeys.length === 0) return next();
|
|
475
|
+
|
|
476
|
+
const authHeader = req.headers.authorization;
|
|
477
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
478
|
+
logError('Отсутствует или некорректный заголовок авторизации');
|
|
479
|
+
return res.status(401).json({ error: 'Требуется авторизация' });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const token = authHeader.substring(7).trim();
|
|
483
|
+
if (!apiKeys.includes(token)) {
|
|
484
|
+
logError('Предоставлен недействительный API ключ');
|
|
485
|
+
return res.status(401).json({ error: 'Недействительный токен' });
|
|
486
|
+
}
|
|
487
|
+
next();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
router.use(authMiddleware);
|
|
491
|
+
router.use((req, res, next) => {
|
|
492
|
+
req.url = req.url.replace(/\/v[12](?=\/|$)/g, '').replace(/\/+/g, '/');
|
|
493
|
+
next();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ─── Helpers: message parsing ────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
async function parseOpenAIMessages(messages) {
|
|
499
|
+
const systemMsg = messages.find(msg => msg.role === 'system');
|
|
500
|
+
const systemMessage = systemMsg ? systemMsg.content : null;
|
|
501
|
+
const lastUserMessage = messages.filter(msg => msg.role === 'user').pop();
|
|
502
|
+
|
|
503
|
+
if (!lastUserMessage) {
|
|
504
|
+
return { messageContent: null, systemMessage };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let messageContent = lastUserMessage.content;
|
|
508
|
+
const extractedFiles = [];
|
|
509
|
+
|
|
510
|
+
// Преобразуем OpenAI format content array во внутренний формат
|
|
511
|
+
if (Array.isArray(messageContent)) {
|
|
512
|
+
messageContent = messageContent.map(item => {
|
|
513
|
+
if (item.type === 'text') {
|
|
514
|
+
return { type: 'text', text: item.text };
|
|
515
|
+
} else if (item.type === 'image_url' && item.image_url) {
|
|
516
|
+
// OpenAI format: image_url: { url: '...' }
|
|
517
|
+
// Извлекаем для загрузки в OSS
|
|
518
|
+
extractedFiles.push({url: item.image_url.url});
|
|
519
|
+
return null; // Удаляем, файлы будут добавлены позже
|
|
520
|
+
} else if (item.type === 'image') {
|
|
521
|
+
// Уже во внутреннем формате
|
|
522
|
+
extractedFiles.push({url: item.image});
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
return item;
|
|
526
|
+
}).filter(item => item !== null); // Убираем null
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Поддерживаем также формат: content: 'text', files: [{url: '...'}]
|
|
530
|
+
// Добавляем файлы из lastUserMessage.files в extractedFiles
|
|
531
|
+
if (lastUserMessage.files && Array.isArray(lastUserMessage.files)) {
|
|
532
|
+
lastUserMessage.files.forEach(f => {
|
|
533
|
+
if (f.url) {
|
|
534
|
+
extractedFiles.push({url: f.url});
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Используем только extractedFiles (уже содержит все файлы)
|
|
540
|
+
const rawFiles = extractedFiles;
|
|
541
|
+
|
|
542
|
+
// Обрабатываем все файлы (base64 -> OSS, локальные -> OSS, URLs -> как есть)
|
|
543
|
+
const files = rawFiles.length > 0 ? await processFilesForQwen(rawFiles) : [];
|
|
544
|
+
|
|
545
|
+
// Добавляем файлы в messageContent
|
|
546
|
+
if (Array.isArray(messageContent) && files.length > 0) {
|
|
547
|
+
messageContent = [...messageContent, ...files];
|
|
548
|
+
} else if (files.length > 0) {
|
|
549
|
+
// Если messageContent был строкой, превращаем в массив
|
|
550
|
+
messageContent = [
|
|
551
|
+
{ type: 'text', text: messageContent },
|
|
552
|
+
...files
|
|
553
|
+
];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return { messageContent, systemMessage };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function buildCombinedTools(tools, functions, toolChoice) {
|
|
560
|
+
const combinedTools = tools || (functions ? functions.map(fn => ({ type: 'function', function: fn })) : null);
|
|
561
|
+
return { combinedTools, toolChoice };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ─── Helpers: streaming ──────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
async function handleStreamingResponse(res, mappedModel, messageContent, chatId, parentId, combinedTools, toolChoice, systemMessage) {
|
|
567
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
568
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
569
|
+
res.setHeader('Connection', 'keep-alive');
|
|
570
|
+
|
|
571
|
+
const writeSse = (payload) => res.write('data: ' + JSON.stringify(payload) + '\n\n');
|
|
572
|
+
|
|
573
|
+
writeSse({
|
|
574
|
+
id: 'chatcmpl-stream', object: 'chat.completion.chunk',
|
|
575
|
+
created: Math.floor(Date.now() / 1000), model: mappedModel,
|
|
576
|
+
choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }]
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const result = await sendMessage(messageContent, mappedModel, chatId, parentId, null, combinedTools, toolChoice, systemMessage);
|
|
581
|
+
|
|
582
|
+
if (result.error) {
|
|
583
|
+
writeSse({
|
|
584
|
+
id: 'chatcmpl-stream', object: 'chat.completion.chunk',
|
|
585
|
+
created: Math.floor(Date.now() / 1000), model: mappedModel,
|
|
586
|
+
choices: [{ index: 0, delta: { content: `Error: ${result.error}` }, finish_reason: null }]
|
|
587
|
+
});
|
|
588
|
+
} else if (result.choices?.[0]?.message) {
|
|
589
|
+
const content = String(result.choices[0].message.content || '');
|
|
590
|
+
const codePoints = Array.from(content);
|
|
591
|
+
const chunkSize = 16;
|
|
592
|
+
for (let i = 0; i < codePoints.length; i += chunkSize) {
|
|
593
|
+
writeSse({
|
|
594
|
+
id: 'chatcmpl-stream', object: 'chat.completion.chunk',
|
|
595
|
+
created: Math.floor(Date.now() / 1000), model: mappedModel,
|
|
596
|
+
choices: [{ index: 0, delta: { content: codePoints.slice(i, i + chunkSize).join('') }, finish_reason: null }]
|
|
597
|
+
});
|
|
598
|
+
await new Promise(r => setTimeout(r, STREAMING_CHUNK_DELAY));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
writeSse({
|
|
603
|
+
id: 'chatcmpl-stream', object: 'chat.completion.chunk',
|
|
604
|
+
created: Math.floor(Date.now() / 1000), model: mappedModel,
|
|
605
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }]
|
|
606
|
+
});
|
|
607
|
+
res.write('data: [DONE]\n\n');
|
|
608
|
+
res.end();
|
|
609
|
+
} catch (error) {
|
|
610
|
+
logError('Ошибка при обработке потокового запроса', error);
|
|
611
|
+
writeSse({
|
|
612
|
+
id: 'chatcmpl-stream', object: 'chat.completion.chunk',
|
|
613
|
+
created: Math.floor(Date.now() / 1000), model: mappedModel,
|
|
614
|
+
choices: [{ index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' }]
|
|
615
|
+
});
|
|
616
|
+
res.write('data: [DONE]\n\n');
|
|
617
|
+
res.end();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function handleNonStreamingResponse(res, result, mappedModel) {
|
|
622
|
+
if (result.error) {
|
|
623
|
+
return res.status(500).json({ error: { message: result.error, type: 'server_error' } });
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
res.json({
|
|
627
|
+
id: result.id || 'chatcmpl-' + Date.now(),
|
|
628
|
+
object: 'chat.completion',
|
|
629
|
+
created: Math.floor(Date.now() / 1000),
|
|
630
|
+
model: result.model || mappedModel,
|
|
631
|
+
choices: result.choices || [{ index: 0, message: { role: 'assistant', content: '' }, finish_reason: 'stop' }],
|
|
632
|
+
usage: result.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
633
|
+
chatId: result.chatId,
|
|
634
|
+
parentId: result.parentId
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ─── Routes ──────────────────────────────────────────────────────────────────
|
|
639
|
+
|
|
640
|
+
router.post('/chat', async (req, res) => {
|
|
641
|
+
try {
|
|
642
|
+
const { message, messages, model, chatId, parentId, stream } = req.body;
|
|
643
|
+
|
|
644
|
+
// Поддержка как message, так и messages для совместимости
|
|
645
|
+
let messageContent = message;
|
|
646
|
+
let systemMessage = null;
|
|
647
|
+
let allMessages = messages; // Сохраняем всю историю
|
|
648
|
+
const isMeta = isOpenWebUiMetaRequest(messages);
|
|
649
|
+
|
|
650
|
+
if (messages && Array.isArray(messages)) {
|
|
651
|
+
const parsed = await parseOpenAIMessages(messages);
|
|
652
|
+
systemMessage = parsed.systemMessage;
|
|
653
|
+
if (parsed.messageContent) messageContent = parsed.messageContent;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!messageContent) {
|
|
657
|
+
logError('Запрос без сообщения');
|
|
658
|
+
return res.status(400).json({ error: 'Сообщение не указано' });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
logInfo(`Получен запрос: ${typeof messageContent === 'string' ? messageContent.substring(0, 50) + (messageContent.length > 50 ? '...' : '') : 'Составное сообщение'}`);
|
|
662
|
+
if (systemMessage) {
|
|
663
|
+
logInfo(`System message: ${systemMessage.substring(0, 50)}${systemMessage.length > 50 ? '...' : ''}`);
|
|
664
|
+
}
|
|
665
|
+
if (chatId && !isMeta) {
|
|
666
|
+
logInfo(`Используется chatId: ${chatId}, parentId: ${parentId || 'null'}`);
|
|
667
|
+
} else if (isMeta) {
|
|
668
|
+
logDebug('OpenWebUI meta-запрос: используем отдельный чат (без привязки к сессии)');
|
|
669
|
+
}
|
|
670
|
+
if (allMessages && allMessages.length > 1) {
|
|
671
|
+
logInfo(`История содержит ${allMessages.length} сообщений`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let mappedModel = model || getActiveModel();
|
|
675
|
+
if (model) {
|
|
676
|
+
mappedModel = getMappedModel(model);
|
|
677
|
+
if (mappedModel !== model) {
|
|
678
|
+
logInfo(`Модель "${model}" заменена на "${mappedModel}"`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
logInfo(`Используется модель: ${mappedModel}`);
|
|
682
|
+
|
|
683
|
+
// Поддержка стриминга для OpenWebUI
|
|
684
|
+
if (stream) {
|
|
685
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
686
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
687
|
+
res.setHeader('Connection', 'keep-alive');
|
|
688
|
+
// Важно для OpenWebUI - не кэшировать
|
|
689
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
690
|
+
|
|
691
|
+
const writeSse = (payload) => {
|
|
692
|
+
res.write('data: ' + JSON.stringify(payload) + '\n\n');
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
// Setup streaming callback
|
|
697
|
+
let streamingCallback = null;
|
|
698
|
+
let hasStreamedChunks = false;
|
|
699
|
+
if (stream) {
|
|
700
|
+
streamingCallback = (chunk) => {
|
|
701
|
+
hasStreamedChunks = true;
|
|
702
|
+
writeSse({
|
|
703
|
+
id: 'chatcmpl-' + Date.now(),
|
|
704
|
+
object: 'chat.completion.chunk',
|
|
705
|
+
created: Math.floor(Date.now() / 1000),
|
|
706
|
+
model: mappedModel,
|
|
707
|
+
choices: [
|
|
708
|
+
{ index: 0, delta: { content: chunk }, finish_reason: null }
|
|
709
|
+
]
|
|
710
|
+
});
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const result = await sendMessage(
|
|
715
|
+
messageContent,
|
|
716
|
+
mappedModel,
|
|
717
|
+
isMeta ? null : chatId,
|
|
718
|
+
isMeta ? null : parentId,
|
|
719
|
+
null,
|
|
720
|
+
null,
|
|
721
|
+
null,
|
|
722
|
+
systemMessage,
|
|
723
|
+
't2t',
|
|
724
|
+
null,
|
|
725
|
+
true,
|
|
726
|
+
0,
|
|
727
|
+
streamingCallback
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
if (result.error) {
|
|
731
|
+
writeSse({
|
|
732
|
+
id: 'chatcmpl-' + Date.now(),
|
|
733
|
+
object: 'chat.completion.chunk',
|
|
734
|
+
created: Math.floor(Date.now() / 1000),
|
|
735
|
+
model: mappedModel,
|
|
736
|
+
choices: [
|
|
737
|
+
{ index: 0, delta: { content: `Error: ${result.error}` }, finish_reason: 'stop' }
|
|
738
|
+
]
|
|
739
|
+
});
|
|
740
|
+
} else if (!hasStreamedChunks && result.choices && result.choices[0] && result.choices[0].message && result.choices[0].message.content) {
|
|
741
|
+
// Qwen вернул JSON вместо SSE - отправляем контент одним чанком
|
|
742
|
+
const content = result.choices[0].message.content;
|
|
743
|
+
logDebug(`JSON response content length: ${content.length}`);
|
|
744
|
+
if (typeof streamingCallback === 'function') {
|
|
745
|
+
streamingCallback(content);
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
logDebug(`Result structure: ${JSON.stringify(Object.keys(result))}`);
|
|
749
|
+
}
|
|
750
|
+
// Чанки уже были отправлены через streamingCallback, не дублируем!
|
|
751
|
+
|
|
752
|
+
// Финальный чанк
|
|
753
|
+
writeSse({
|
|
754
|
+
id: 'chatcmpl-' + Date.now(),
|
|
755
|
+
object: 'chat.completion.chunk',
|
|
756
|
+
created: Math.floor(Date.now() / 1000),
|
|
757
|
+
model: mappedModel,
|
|
758
|
+
choices: [
|
|
759
|
+
{ index: 0, delta: {}, finish_reason: 'stop' }
|
|
760
|
+
]
|
|
761
|
+
});
|
|
762
|
+
res.write('data: [DONE]\n\n');
|
|
763
|
+
res.end();
|
|
764
|
+
return;
|
|
765
|
+
} catch (error) {
|
|
766
|
+
logError('Ошибка при обработке потокового запроса', error);
|
|
767
|
+
writeSse({
|
|
768
|
+
id: 'chatcmpl-stream',
|
|
769
|
+
object: 'chat.completion.chunk',
|
|
770
|
+
created: Math.floor(Date.now() / 1000),
|
|
771
|
+
model: mappedModel,
|
|
772
|
+
choices: [
|
|
773
|
+
{ index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' }
|
|
774
|
+
]
|
|
775
|
+
});
|
|
776
|
+
res.write('data: [DONE]\n\n');
|
|
777
|
+
res.end();
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const result = await sendMessage(messageContent, mappedModel, isMeta ? null : chatId, isMeta ? null : parentId, null, null, null, systemMessage);
|
|
783
|
+
|
|
784
|
+
if (result.choices && result.choices[0] && result.choices[0].message) {
|
|
785
|
+
const responseLength = result.choices[0].message.content ? result.choices[0].message.content.length : 0;
|
|
786
|
+
logInfo(`Ответ успешно сформирован для запроса, длина ответа: ${responseLength}`);
|
|
787
|
+
|
|
788
|
+
// Сохраняем историю чата
|
|
789
|
+
if (result.chatId) {
|
|
790
|
+
try {
|
|
791
|
+
const currentChat = loadHistory(result.chatId);
|
|
792
|
+
const updatedMessages = allMessages || [
|
|
793
|
+
{ role: 'user', content: messageContent },
|
|
794
|
+
{ role: 'assistant', content: result.choices[0].message.content }
|
|
795
|
+
];
|
|
796
|
+
saveHistory(result.chatId, { ...currentChat, messages: updatedMessages });
|
|
797
|
+
} catch (e) {
|
|
798
|
+
logDebug(`Не удалось сохранить историю: ${e.message}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
} else if (result.error) {
|
|
802
|
+
logInfo(`Получена ошибка в ответе: ${result.error}`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
res.json(result);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
logError('Ошибка при обработке запроса', error);
|
|
808
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
router.get('/models', async (req, res) => {
|
|
813
|
+
try {
|
|
814
|
+
logInfo('Запрос на получение списка моделей');
|
|
815
|
+
const modelsRaw = getAllModels();
|
|
816
|
+
const openAiModels = {
|
|
817
|
+
object: 'list',
|
|
818
|
+
data: modelsRaw.models.map(m => ({
|
|
819
|
+
id: m.id || m.name || m,
|
|
820
|
+
object: 'model',
|
|
821
|
+
created: 0,
|
|
822
|
+
owned_by: 'qwen',
|
|
823
|
+
permission: []
|
|
824
|
+
}))
|
|
825
|
+
};
|
|
826
|
+
logInfo(`Возвращено ${openAiModels.data.length} моделей (OpenAI формат)`);
|
|
827
|
+
res.json(openAiModels);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
logError('Ошибка при получении списка моделей', error);
|
|
830
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
router.get('/status', async (req, res) => {
|
|
835
|
+
try {
|
|
836
|
+
logInfo('Запрос статуса авторизации');
|
|
837
|
+
const tokens = listTokens();
|
|
838
|
+
const now = Date.now();
|
|
839
|
+
|
|
840
|
+
// Фильтруем только действительные токены с cookies
|
|
841
|
+
const validTokens = tokens.filter(t => {
|
|
842
|
+
if (t.invalid) return false;
|
|
843
|
+
if (t.resetAt && new Date(t.resetAt).getTime() > now) return false;
|
|
844
|
+
if (t.expiryTime && t.expiryTime <= now) return false;
|
|
845
|
+
// Проверяем наличие cookies.json
|
|
846
|
+
const cookiesPath = path.join(process.cwd(), SESSION_DIR, 'accounts', t.id, 'cookies.json');
|
|
847
|
+
if (!fs.existsSync(cookiesPath)) return false;
|
|
848
|
+
return true;
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const accounts = await Promise.all(validTokens.map(async t => {
|
|
852
|
+
const accInfo = { id: t.id, status: 'UNKNOWN', resetAt: t.resetAt || null };
|
|
853
|
+
|
|
854
|
+
if (t.resetAt) {
|
|
855
|
+
const resetTime = new Date(t.resetAt).getTime();
|
|
856
|
+
if (resetTime > Date.now()) { accInfo.status = 'WAIT'; return accInfo; }
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const testResult = await testToken(t.token);
|
|
860
|
+
if (testResult === 'OK') { accInfo.status = 'OK'; if (t.invalid || t.resetAt) markValid(t.id); }
|
|
861
|
+
else if (testResult === 'RATELIMIT') { accInfo.status = 'WAIT'; markRateLimited(t.id, 24); }
|
|
862
|
+
else if (testResult === 'UNAUTHORIZED') { accInfo.status = 'INVALID'; if (!t.invalid) markInvalid(t.id); }
|
|
863
|
+
else { accInfo.status = 'ERROR'; }
|
|
864
|
+
return accInfo;
|
|
865
|
+
}));
|
|
866
|
+
|
|
867
|
+
const browserContext = getBrowserContext();
|
|
868
|
+
if (!browserContext) {
|
|
869
|
+
logError('Браузер не инициализирован');
|
|
870
|
+
return res.json({ authenticated: false, message: 'Браузер не инициализирован', accounts });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (getAuthenticationStatus()) return res.json({ accounts });
|
|
874
|
+
|
|
875
|
+
await checkAuthentication(browserContext);
|
|
876
|
+
const isAuthenticated = getAuthenticationStatus();
|
|
877
|
+
logInfo(`Статус авторизации: ${isAuthenticated ? 'активна' : 'требуется авторизация'}`);
|
|
878
|
+
res.json({ authenticated: isAuthenticated, message: isAuthenticated ? 'Авторизация активна' : 'Требуется авторизация', accounts });
|
|
879
|
+
} catch (error) {
|
|
880
|
+
logError('Ошибка при проверке статуса авторизации', error);
|
|
881
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
router.post('/chats', async (req, res) => {
|
|
886
|
+
try {
|
|
887
|
+
const { name, model } = req.body;
|
|
888
|
+
// Приоритет: переданная модель > activeModel из настроек
|
|
889
|
+
const chatModel = model ? getMappedModel(model) : getActiveModel();
|
|
890
|
+
logInfo(`Создание нового чата${name ? ` с именем: ${name}` : ''}, модель: ${chatModel}`);
|
|
891
|
+
const result = await createChatV2(chatModel, name || 'Новый чат');
|
|
892
|
+
if (result.error) { logError(`Ошибка создания чата: ${result.error}`); return res.status(500).json({ error: result.error }); }
|
|
893
|
+
logInfo(`Создан новый чат v2 с ID: ${result.chatId}`);
|
|
894
|
+
res.json({ chatId: result.chatId, success: true });
|
|
895
|
+
} catch (error) {
|
|
896
|
+
logError('Ошибка при создании чата', error);
|
|
897
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
router.get('/chat/completions', (req, res) => {
|
|
902
|
+
res.status(405).json({
|
|
903
|
+
error: 'Метод не поддерживается',
|
|
904
|
+
message: 'Используйте POST /api/chat/completions'
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
router.post('/chat/completions', async (req, res) => {
|
|
909
|
+
try {
|
|
910
|
+
const { messages, model, stream, tools, functions, tool_choice, chatId } = req.body;
|
|
911
|
+
const snakeCaseChatId = normalizeIdValue(req.body?.chat_id);
|
|
912
|
+
const explicitChatId = normalizeIdValue(chatId) || snakeCaseChatId;
|
|
913
|
+
const explicitParentId = extractParentHint(req);
|
|
914
|
+
const conversationHint = extractConversationHint(req);
|
|
915
|
+
const conversationScope = conversationHint ? `conversation:${conversationHint}` : null;
|
|
916
|
+
const forceNewChat = shouldForceNewChat(req);
|
|
917
|
+
logInfo(`Получен OpenAI-совместимый запрос${stream ? ' (stream)' : ''}`);
|
|
918
|
+
|
|
919
|
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
920
|
+
logError('Запрос без сообщений');
|
|
921
|
+
return res.status(400).json({ error: 'Сообщения не указаны' });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const isMeta = isOpenWebUiMetaRequest(messages);
|
|
925
|
+
|
|
926
|
+
// Используем переданный chatId ИЛИ восстанавливаем из сессии
|
|
927
|
+
let effectiveChatId = explicitChatId;
|
|
928
|
+
let effectiveParentId = explicitParentId;
|
|
929
|
+
|
|
930
|
+
if (forceNewChat && !explicitChatId && !isMeta) {
|
|
931
|
+
effectiveChatId = `chat_${crypto.randomBytes(8).toString('hex')}`;
|
|
932
|
+
effectiveParentId = null;
|
|
933
|
+
logInfo(`Принудительно запрошен новый чат (newChat/resetChat): ${effectiveChatId}`);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (!effectiveChatId && !isMeta) {
|
|
937
|
+
if (conversationHint) {
|
|
938
|
+
const scopedSession = forceNewChat ? null : getSavedChatId(req, conversationScope);
|
|
939
|
+
if (scopedSession?.chatId) {
|
|
940
|
+
effectiveChatId = scopedSession.chatId;
|
|
941
|
+
if (!effectiveParentId && scopedSession.parentId) {
|
|
942
|
+
effectiveParentId = scopedSession.parentId;
|
|
943
|
+
}
|
|
944
|
+
logInfo(`Restored scoped chatId from session: ${effectiveChatId}`);
|
|
945
|
+
} else {
|
|
946
|
+
effectiveChatId = buildInternalChatIdFromHint(conversationHint);
|
|
947
|
+
logInfo(`Using client conversation-id key: ${effectiveChatId}`);
|
|
948
|
+
}
|
|
949
|
+
} else if (ALLOW_UNSCOPED_SESSION_CHAT_RESTORE) {
|
|
950
|
+
const savedSession = forceNewChat ? null : getSavedChatId(req);
|
|
951
|
+
if (savedSession?.chatId) {
|
|
952
|
+
effectiveChatId = savedSession.chatId;
|
|
953
|
+
if (!effectiveParentId && savedSession.parentId) {
|
|
954
|
+
effectiveParentId = savedSession.parentId;
|
|
955
|
+
}
|
|
956
|
+
logInfo(`Restored chatId from session: ${effectiveChatId}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (!effectiveChatId) {
|
|
960
|
+
const generatedId = generateChatIdFromHistory(messages);
|
|
961
|
+
if (generatedId) {
|
|
962
|
+
effectiveChatId = generatedId;
|
|
963
|
+
logInfo(`Created new chatId for session: ${effectiveChatId}`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
} else {
|
|
967
|
+
logDebug('chatId/conversation_id не переданы, unscoped session fallback отключён');
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Извлекаем system message если есть
|
|
972
|
+
const systemMsg = messages.find(msg => msg.role === 'system');
|
|
973
|
+
const systemMessage = systemMsg ? systemMsg.content : null;
|
|
974
|
+
|
|
975
|
+
const lastUserMessage = messages.filter(msg => msg.role === 'user').pop();
|
|
976
|
+
if (!lastUserMessage) {
|
|
977
|
+
logError('В запросе нет сообщений от пользователя');
|
|
978
|
+
return res.status(400).json({ error: 'В запросе нет сообщений от пользователя' });
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
let messageContent = lastUserMessage.content;
|
|
982
|
+
|
|
983
|
+
// Преобразуем OpenAI format content array во внутренний формат
|
|
984
|
+
const extractedFiles = []; // Files из content array
|
|
985
|
+
|
|
986
|
+
if (Array.isArray(messageContent)) {
|
|
987
|
+
messageContent = messageContent.map(item => {
|
|
988
|
+
if (item.type === 'text') {
|
|
989
|
+
return { type: 'text', text: item.text };
|
|
990
|
+
} else if (item.type === 'image_url' && item.image_url) {
|
|
991
|
+
// OpenAI format: image_url: { url: '...' }
|
|
992
|
+
// Извлекаем URL/base64 для загрузки в OSS
|
|
993
|
+
extractedFiles.push({url: item.image_url.url});
|
|
994
|
+
return { type: 'text', text: '' }; // Заменяем на пустой текст, файлы будут в files
|
|
995
|
+
} else if (item.type === 'image') {
|
|
996
|
+
// Уже во внутреннем формате
|
|
997
|
+
extractedFiles.push({url: item.image});
|
|
998
|
+
return { type: 'text', text: '' };
|
|
999
|
+
}
|
|
1000
|
+
return item;
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// Убираем пустые text элементы
|
|
1004
|
+
messageContent = messageContent.filter(item => item.type !== 'text' || item.text.trim());
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Поддерживаем также формат: content: 'text', files: [{url: '...'}]
|
|
1008
|
+
// Добавляем файлы из lastUserMessage.files в extractedFiles
|
|
1009
|
+
if (lastUserMessage.files && Array.isArray(lastUserMessage.files)) {
|
|
1010
|
+
lastUserMessage.files.forEach(f => {
|
|
1011
|
+
if (f.url) {
|
|
1012
|
+
extractedFiles.push({url: f.url});
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Используем только extractedFiles (уже содержит все файлы)
|
|
1018
|
+
const rawFiles = extractedFiles;
|
|
1019
|
+
|
|
1020
|
+
// Обрабатываем все файлы (base64 -> OSS, локальные -> OSS, URLs -> как есть)
|
|
1021
|
+
const files = rawFiles.length > 0 ? await processFilesForQwen(rawFiles) : [];
|
|
1022
|
+
|
|
1023
|
+
// Добавляем файлы в messageContent
|
|
1024
|
+
if (Array.isArray(messageContent) && files.length > 0) {
|
|
1025
|
+
messageContent = [...messageContent, ...files];
|
|
1026
|
+
} else if (files.length > 0) {
|
|
1027
|
+
messageContent = [
|
|
1028
|
+
{ type: 'text', text: messageContent },
|
|
1029
|
+
...files
|
|
1030
|
+
];
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Файлы уже встроены в messageContent, не передаем отдельно чтобы избежать дублирования
|
|
1034
|
+
const filesForSend = null;
|
|
1035
|
+
|
|
1036
|
+
if (isMeta) {
|
|
1037
|
+
effectiveChatId = null;
|
|
1038
|
+
effectiveParentId = null;
|
|
1039
|
+
logDebug('OpenWebUI meta-запрос: используем отдельный чат (без привязки к сессии)');
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Приоритет: переданная модель > activeModel из настроек
|
|
1043
|
+
let mappedModel = model ? getMappedModel(model) : getActiveModel();
|
|
1044
|
+
if (model && mappedModel !== model) {
|
|
1045
|
+
logInfo(`Модель "${model}" заменена на "${mappedModel}"`);
|
|
1046
|
+
}
|
|
1047
|
+
logInfo(`Используется модель: ${mappedModel}`);
|
|
1048
|
+
if (systemMessage) logInfo(`System message: ${systemMessage.substring(0, 50)}${systemMessage.length > 50 ? '...' : ''}`);
|
|
1049
|
+
|
|
1050
|
+
const { combinedTools } = buildCombinedTools(tools, functions, tool_choice);
|
|
1051
|
+
|
|
1052
|
+
if (systemMessage) {
|
|
1053
|
+
logInfo(`System message: ${systemMessage.substring(0, 50)}${systemMessage.length > 50 ? '...' : ''}`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Логируем полную историю сообщений
|
|
1057
|
+
logInfo(`История содержит ${messages.length} сообщений: ${messages.map(m => m.role).join(', ')}`);
|
|
1058
|
+
if (effectiveChatId) {
|
|
1059
|
+
logInfo(`Используется chatId: ${effectiveChatId}, parentId: ${effectiveParentId || 'null'}`);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (stream) {
|
|
1063
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1064
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1065
|
+
res.setHeader('Pragma', 'no-cache');
|
|
1066
|
+
res.setHeader('Expires', '0');
|
|
1067
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1068
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
1069
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
1070
|
+
|
|
1071
|
+
const writeSse = (payload) => {
|
|
1072
|
+
res.write('data: ' + JSON.stringify(payload) + '\n\n');
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
try {
|
|
1076
|
+
const combinedTools = tools || (functions ? functions.map(fn => ({ type: 'function', function: fn })) : null);
|
|
1077
|
+
const qwenChatId = await resolveQwenChatId(effectiveChatId, mappedModel);
|
|
1078
|
+
|
|
1079
|
+
// Setup streaming callback if stream=true
|
|
1080
|
+
let streamingCallback = null;
|
|
1081
|
+
let hasStreamedChunks = false;
|
|
1082
|
+
if (stream) {
|
|
1083
|
+
streamingCallback = (chunk) => {
|
|
1084
|
+
hasStreamedChunks = true;
|
|
1085
|
+
writeSse({
|
|
1086
|
+
id: 'chatcmpl-stream',
|
|
1087
|
+
object: 'chat.completion.chunk',
|
|
1088
|
+
created: Math.floor(Date.now() / 1000),
|
|
1089
|
+
model: mappedModel,
|
|
1090
|
+
choices: [
|
|
1091
|
+
{ index: 0, delta: { content: chunk }, finish_reason: null }
|
|
1092
|
+
]
|
|
1093
|
+
});
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const result = await sendMessage(
|
|
1098
|
+
messageContent,
|
|
1099
|
+
mappedModel,
|
|
1100
|
+
qwenChatId,
|
|
1101
|
+
effectiveParentId,
|
|
1102
|
+
filesForSend, // ← Файлы уже в messageContent, не передаем отдельно
|
|
1103
|
+
combinedTools,
|
|
1104
|
+
tool_choice,
|
|
1105
|
+
systemMessage,
|
|
1106
|
+
't2t',
|
|
1107
|
+
null,
|
|
1108
|
+
true,
|
|
1109
|
+
0,
|
|
1110
|
+
streamingCallback
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
// Сохраняем chatId в сессию для следующих запросов
|
|
1114
|
+
if (!isMeta && result.chatId) {
|
|
1115
|
+
// Если мы использовали сгенерированный effectiveChatId — сохраните маппинг
|
|
1116
|
+
if (effectiveChatId && effectiveChatId.startsWith('chat_') && result.chatId) {
|
|
1117
|
+
mapChatId(effectiveChatId, result.chatId);
|
|
1118
|
+
logDebug(`Маппинг сохранён: ${effectiveChatId} -> ${result.chatId}`);
|
|
1119
|
+
}
|
|
1120
|
+
if (shouldPersistSessionContext(conversationScope)) {
|
|
1121
|
+
saveChatIdForSession(req, result.chatId, result.parentId, conversationScope);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (result.error) {
|
|
1126
|
+
writeSse({
|
|
1127
|
+
id: 'chatcmpl-stream',
|
|
1128
|
+
object: 'chat.completion.chunk',
|
|
1129
|
+
created: Math.floor(Date.now() / 1000),
|
|
1130
|
+
model: mappedModel,
|
|
1131
|
+
choices: [
|
|
1132
|
+
{ index: 0, delta: { content: `Error: ${result.error}` }, finish_reason: null }
|
|
1133
|
+
]
|
|
1134
|
+
});
|
|
1135
|
+
} else if (!hasStreamedChunks && result.choices && result.choices[0] && result.choices[0].message && result.choices[0].message.content) {
|
|
1136
|
+
// Qwen вернул JSON вместо SSE - отправляем контент одним чанком
|
|
1137
|
+
const content = result.choices[0].message.content;
|
|
1138
|
+
logDebug(`JSON response content length: ${content.length}`);
|
|
1139
|
+
if (typeof streamingCallback === 'function') {
|
|
1140
|
+
streamingCallback(content);
|
|
1141
|
+
}
|
|
1142
|
+
} else {
|
|
1143
|
+
logDebug(`Result structure: ${JSON.stringify(Object.keys(result))}`);
|
|
1144
|
+
}
|
|
1145
|
+
// Чанки уже были отправлены через streamingCallback, не дублируем!
|
|
1146
|
+
|
|
1147
|
+
writeSse({
|
|
1148
|
+
id: 'chatcmpl-stream',
|
|
1149
|
+
object: 'chat.completion.chunk',
|
|
1150
|
+
created: Math.floor(Date.now() / 1000),
|
|
1151
|
+
model: mappedModel,
|
|
1152
|
+
choices: [
|
|
1153
|
+
{ index: 0, delta: {}, finish_reason: 'stop' }
|
|
1154
|
+
]
|
|
1155
|
+
});
|
|
1156
|
+
res.write('data: [DONE]\n\n');
|
|
1157
|
+
res.end();
|
|
1158
|
+
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
logError('Ошибка при обработке потокового запроса', error);
|
|
1161
|
+
writeSse({
|
|
1162
|
+
id: 'chatcmpl-stream',
|
|
1163
|
+
object: 'chat.completion.chunk',
|
|
1164
|
+
created: Math.floor(Date.now() / 1000),
|
|
1165
|
+
model: mappedModel,
|
|
1166
|
+
choices: [
|
|
1167
|
+
{ index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' }
|
|
1168
|
+
]
|
|
1169
|
+
});
|
|
1170
|
+
res.write('data: [DONE]\n\n');
|
|
1171
|
+
res.end();
|
|
1172
|
+
}
|
|
1173
|
+
} else {
|
|
1174
|
+
const combinedTools = tools || (functions ? functions.map(fn => ({ type: 'function', function: fn })) : null);
|
|
1175
|
+
const qwenChatId = await resolveQwenChatId(effectiveChatId, mappedModel);
|
|
1176
|
+
const result = await sendMessage(messageContent, mappedModel, qwenChatId, effectiveParentId, null, combinedTools, tool_choice, systemMessage);
|
|
1177
|
+
|
|
1178
|
+
// Сохраняем chatId в сессию для следующих запросов
|
|
1179
|
+
if (!isMeta && result.chatId) {
|
|
1180
|
+
if (effectiveChatId && effectiveChatId.startsWith('chat_') && result.chatId) {
|
|
1181
|
+
mapChatId(effectiveChatId, result.chatId);
|
|
1182
|
+
logDebug(`Маппинг сохранён: ${effectiveChatId} -> ${result.chatId}`);
|
|
1183
|
+
}
|
|
1184
|
+
if (shouldPersistSessionContext(conversationScope)) {
|
|
1185
|
+
saveChatIdForSession(req, result.chatId, result.parentId, conversationScope);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (result.error) {
|
|
1190
|
+
return res.status(500).json({
|
|
1191
|
+
error: { message: result.error, type: "server_error" }
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const openaiResponse = {
|
|
1196
|
+
id: result.id || "chatcmpl-" + Date.now(),
|
|
1197
|
+
object: "chat.completion",
|
|
1198
|
+
created: Math.floor(Date.now() / 1000),
|
|
1199
|
+
model: result.model || mappedModel,
|
|
1200
|
+
choices: result.choices || [{
|
|
1201
|
+
index: 0,
|
|
1202
|
+
message: {
|
|
1203
|
+
role: "assistant",
|
|
1204
|
+
content: result.choices?.[0]?.message?.content || ""
|
|
1205
|
+
},
|
|
1206
|
+
finish_reason: "stop"
|
|
1207
|
+
}],
|
|
1208
|
+
usage: result.usage || {
|
|
1209
|
+
prompt_tokens: 0,
|
|
1210
|
+
completion_tokens: 0,
|
|
1211
|
+
total_tokens: 0
|
|
1212
|
+
},
|
|
1213
|
+
chatId: result.chatId,
|
|
1214
|
+
parentId: result.parentId
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
// Сохраняем историю чата
|
|
1218
|
+
if (result.chatId) {
|
|
1219
|
+
try {
|
|
1220
|
+
const currentChat = loadHistory(result.chatId);
|
|
1221
|
+
const responseMessage = {
|
|
1222
|
+
role: 'assistant',
|
|
1223
|
+
content: openaiResponse.choices[0].message.content
|
|
1224
|
+
};
|
|
1225
|
+
const updatedMessages = messages.concat([responseMessage]);
|
|
1226
|
+
saveHistory(result.chatId, { ...currentChat, messages: updatedMessages });
|
|
1227
|
+
} catch (e) {
|
|
1228
|
+
logDebug(`Не удалось сохранить историю: ${e.message}`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
res.json(openaiResponse);
|
|
1233
|
+
}
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
logError('Ошибка при обработке запроса', error);
|
|
1236
|
+
res.status(500).json({ error: { message: 'Внутренняя ошибка сервера', type: "server_error" } });
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
// OpenAI совместимый эндпоинт v1 (для Open WebUI и других клиентов)
|
|
1241
|
+
router.post('/v1/chat/completions', async (req, res) => {
|
|
1242
|
+
try {
|
|
1243
|
+
const { messages, model, stream, tools, functions, tool_choice, chatId } = req.body;
|
|
1244
|
+
const snakeCaseChatId = normalizeIdValue(req.body?.chat_id);
|
|
1245
|
+
const explicitChatId = normalizeIdValue(chatId) || snakeCaseChatId;
|
|
1246
|
+
const explicitParentId = extractParentHint(req);
|
|
1247
|
+
const conversationHint = extractConversationHint(req);
|
|
1248
|
+
const conversationScope = conversationHint ? `conversation:${conversationHint}` : null;
|
|
1249
|
+
const forceNewChat = shouldForceNewChat(req);
|
|
1250
|
+
|
|
1251
|
+
logInfo(`Получен OpenAI v1 запрос${stream ? ' (stream)' : ''}`);
|
|
1252
|
+
|
|
1253
|
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
1254
|
+
logError('Запрос без сообщений');
|
|
1255
|
+
return res.status(400).json({ error: 'Сообщения не указаны' });
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const isMeta = isOpenWebUiMetaRequest(messages);
|
|
1259
|
+
|
|
1260
|
+
// Используем переданный chatId ИЛИ восстанавливаем из сессии
|
|
1261
|
+
let effectiveChatId = explicitChatId;
|
|
1262
|
+
let effectiveParentId = explicitParentId;
|
|
1263
|
+
|
|
1264
|
+
if (forceNewChat && !explicitChatId && !isMeta) {
|
|
1265
|
+
effectiveChatId = `chat_${crypto.randomBytes(8).toString('hex')}`;
|
|
1266
|
+
effectiveParentId = null;
|
|
1267
|
+
logInfo(`Принудительно запрошен новый чат (newChat/resetChat): ${effectiveChatId}`);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (!effectiveChatId && !isMeta) {
|
|
1271
|
+
if (conversationHint) {
|
|
1272
|
+
const scopedSession = forceNewChat ? null : getSavedChatId(req, conversationScope);
|
|
1273
|
+
if (scopedSession?.chatId) {
|
|
1274
|
+
effectiveChatId = scopedSession.chatId;
|
|
1275
|
+
if (!effectiveParentId && scopedSession.parentId) {
|
|
1276
|
+
effectiveParentId = scopedSession.parentId;
|
|
1277
|
+
}
|
|
1278
|
+
logInfo(`Restored scoped chatId from session: ${effectiveChatId}`);
|
|
1279
|
+
} else {
|
|
1280
|
+
effectiveChatId = buildInternalChatIdFromHint(conversationHint);
|
|
1281
|
+
logInfo(`Using client conversation-id key: ${effectiveChatId}`);
|
|
1282
|
+
}
|
|
1283
|
+
} else if (ALLOW_UNSCOPED_SESSION_CHAT_RESTORE) {
|
|
1284
|
+
const savedSession = forceNewChat ? null : getSavedChatId(req);
|
|
1285
|
+
if (savedSession?.chatId) {
|
|
1286
|
+
effectiveChatId = savedSession.chatId;
|
|
1287
|
+
if (!effectiveParentId && savedSession.parentId) {
|
|
1288
|
+
effectiveParentId = savedSession.parentId;
|
|
1289
|
+
}
|
|
1290
|
+
logInfo(`Restored chatId from session: ${effectiveChatId}`);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (!effectiveChatId) {
|
|
1294
|
+
const generatedId = generateChatIdFromHistory(messages);
|
|
1295
|
+
if (generatedId) {
|
|
1296
|
+
effectiveChatId = generatedId;
|
|
1297
|
+
logInfo(`Created new chatId for session: ${effectiveChatId}`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
} else {
|
|
1301
|
+
logDebug('chatId/conversation_id не переданы, unscoped session fallback отключён');
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Извлекаем system message если есть
|
|
1306
|
+
const systemMsg = messages.find(msg => msg.role === 'system');
|
|
1307
|
+
const systemMessage = systemMsg ? systemMsg.content : null;
|
|
1308
|
+
|
|
1309
|
+
const lastUserMessage = messages.filter(msg => msg.role === 'user').pop();
|
|
1310
|
+
if (!lastUserMessage) {
|
|
1311
|
+
logError('В запросе нет сообщений от пользователя');
|
|
1312
|
+
return res.status(400).json({ error: 'В запросе нет сообщений от пользователя' });
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
let messageContent = lastUserMessage.content;
|
|
1316
|
+
|
|
1317
|
+
// Преобразуем OpenAI format content array во внутренний формат
|
|
1318
|
+
const extractedFiles = []; // Files из content array
|
|
1319
|
+
|
|
1320
|
+
if (Array.isArray(messageContent)) {
|
|
1321
|
+
messageContent = messageContent.map(item => {
|
|
1322
|
+
if (item.type === 'text') {
|
|
1323
|
+
return { type: 'text', text: item.text };
|
|
1324
|
+
} else if (item.type === 'image_url' && item.image_url) {
|
|
1325
|
+
// OpenAI format: image_url: { url: '...' }
|
|
1326
|
+
// Извлекаем URL/base64 для загрузки в OSS
|
|
1327
|
+
extractedFiles.push({url: item.image_url.url});
|
|
1328
|
+
return { type: 'text', text: '' }; // Заменяем на пустой текст, файлы будут в files
|
|
1329
|
+
} else if (item.type === 'image') {
|
|
1330
|
+
// Уже во внутреннем формате
|
|
1331
|
+
extractedFiles.push({url: item.image});
|
|
1332
|
+
return { type: 'text', text: '' };
|
|
1333
|
+
}
|
|
1334
|
+
return item;
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// Убираем пустые text элементы
|
|
1338
|
+
messageContent = messageContent.filter(item => item.type !== 'text' || item.text.trim());
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Поддерживаем также формат: content: 'text', files: [{url: '...'}]
|
|
1342
|
+
// Добавляем файлы из lastUserMessage.files в extractedFiles
|
|
1343
|
+
if (lastUserMessage.files && Array.isArray(lastUserMessage.files)) {
|
|
1344
|
+
lastUserMessage.files.forEach(f => {
|
|
1345
|
+
if (f.url) {
|
|
1346
|
+
extractedFiles.push({url: f.url});
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Используем только extractedFiles (уже содержит все файлы)
|
|
1352
|
+
const rawFiles = extractedFiles;
|
|
1353
|
+
|
|
1354
|
+
// Обрабатываем все файлы (base64 -> OSS, локальные -> OSS, URLs -> как есть)
|
|
1355
|
+
const files = rawFiles.length > 0 ? await processFilesForQwen(rawFiles) : [];
|
|
1356
|
+
|
|
1357
|
+
// Добавляем файлы в messageContent
|
|
1358
|
+
if (Array.isArray(messageContent) && files.length > 0) {
|
|
1359
|
+
messageContent = [...messageContent, ...files];
|
|
1360
|
+
} else if (files.length > 0) {
|
|
1361
|
+
messageContent = [
|
|
1362
|
+
{ type: 'text', text: messageContent },
|
|
1363
|
+
...files
|
|
1364
|
+
];
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// Файлы уже встроены в messageContent, не передаем отдельно чтобы избежать дублирования
|
|
1368
|
+
const filesForSend = null;
|
|
1369
|
+
|
|
1370
|
+
if (isMeta) {
|
|
1371
|
+
effectiveChatId = null;
|
|
1372
|
+
effectiveParentId = null;
|
|
1373
|
+
logDebug('OpenWebUI meta-запрос: используем отдельный чат (без привязки к сессии)');
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Приоритет: переданная модель > activeModel из настроек
|
|
1377
|
+
let mappedModel = model ? getMappedModel(model) : getActiveModel();
|
|
1378
|
+
if (model && mappedModel !== model) {
|
|
1379
|
+
logInfo(`Модель "${model}" заменена на "${mappedModel}"`);
|
|
1380
|
+
}
|
|
1381
|
+
logInfo(`Используется модель: ${mappedModel}`);
|
|
1382
|
+
|
|
1383
|
+
if (systemMessage) {
|
|
1384
|
+
logInfo(`System message: ${systemMessage.substring(0, 50)}${systemMessage.length > 50 ? '...' : ''}`);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Логируем полную историю сообщений
|
|
1388
|
+
logInfo(`История содержит ${messages.length} сообщений: ${messages.map(m => m.role).join(', ')}`);
|
|
1389
|
+
if (effectiveChatId) {
|
|
1390
|
+
logInfo(`Используется chatId: ${effectiveChatId}, parentId: ${effectiveParentId || 'null'}`);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
if (stream) {
|
|
1394
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1395
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1396
|
+
res.setHeader('Pragma', 'no-cache');
|
|
1397
|
+
res.setHeader('Expires', '0');
|
|
1398
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1399
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
1400
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
1401
|
+
|
|
1402
|
+
const writeSse = (payload) => {
|
|
1403
|
+
res.write('data: ' + JSON.stringify(payload) + '\n\n');
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
try {
|
|
1407
|
+
const combinedTools = tools || (functions ? functions.map(fn => ({ type: 'function', function: fn })) : null);
|
|
1408
|
+
const qwenChatId = await resolveQwenChatId(effectiveChatId, mappedModel);
|
|
1409
|
+
|
|
1410
|
+
// Setup streaming callback if stream=true
|
|
1411
|
+
let streamingCallback = null;
|
|
1412
|
+
let hasStreamedChunks = false;
|
|
1413
|
+
if (stream) {
|
|
1414
|
+
streamingCallback = (chunk) => {
|
|
1415
|
+
hasStreamedChunks = true;
|
|
1416
|
+
// OpenWebUI не нуждается в role в чанках - только контент
|
|
1417
|
+
writeSse({
|
|
1418
|
+
id: 'chatcmpl-' + Date.now(),
|
|
1419
|
+
object: 'chat.completion.chunk',
|
|
1420
|
+
created: Math.floor(Date.now() / 1000),
|
|
1421
|
+
model: mappedModel,
|
|
1422
|
+
choices: [
|
|
1423
|
+
{ index: 0, delta: { content: chunk }, finish_reason: null }
|
|
1424
|
+
]
|
|
1425
|
+
});
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const result = await sendMessage(
|
|
1430
|
+
messageContent,
|
|
1431
|
+
mappedModel,
|
|
1432
|
+
qwenChatId,
|
|
1433
|
+
effectiveParentId,
|
|
1434
|
+
filesForSend, // ← Файлы уже в messageContent, не передаем отдельно
|
|
1435
|
+
combinedTools,
|
|
1436
|
+
tool_choice,
|
|
1437
|
+
systemMessage,
|
|
1438
|
+
't2t',
|
|
1439
|
+
null,
|
|
1440
|
+
true,
|
|
1441
|
+
0,
|
|
1442
|
+
streamingCallback
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1445
|
+
// Сохраняем chatId в сессию для следующих запросов
|
|
1446
|
+
if (!isMeta && result.chatId) {
|
|
1447
|
+
if (shouldPersistSessionContext(conversationScope)) {
|
|
1448
|
+
saveChatIdForSession(req, result.chatId, result.parentId, conversationScope);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
if (result.error) {
|
|
1453
|
+
writeSse({
|
|
1454
|
+
id: 'chatcmpl-stream',
|
|
1455
|
+
object: 'chat.completion.chunk',
|
|
1456
|
+
created: Math.floor(Date.now() / 1000),
|
|
1457
|
+
model: mappedModel,
|
|
1458
|
+
choices: [
|
|
1459
|
+
{ index: 0, delta: { content: `Error: ${result.error}` }, finish_reason: 'stop' }
|
|
1460
|
+
]
|
|
1461
|
+
});
|
|
1462
|
+
} else if (!hasStreamedChunks && result.choices && result.choices[0] && result.choices[0].message && result.choices[0].message.content) {
|
|
1463
|
+
// Qwen вернул JSON вместо SSE - отправляем контент одним чанком
|
|
1464
|
+
const content = result.choices[0].message.content;
|
|
1465
|
+
logDebug(`JSON response content length: ${content.length}`);
|
|
1466
|
+
if (typeof streamingCallback === 'function') {
|
|
1467
|
+
streamingCallback(content);
|
|
1468
|
+
}
|
|
1469
|
+
} else {
|
|
1470
|
+
logDebug(`Result structure: ${JSON.stringify(Object.keys(result))}`);
|
|
1471
|
+
}
|
|
1472
|
+
// Чанки уже были отправлены через streamingCallback, не дублируем!
|
|
1473
|
+
|
|
1474
|
+
writeSse({
|
|
1475
|
+
id: 'chatcmpl-stream',
|
|
1476
|
+
object: 'chat.completion.chunk',
|
|
1477
|
+
created: Math.floor(Date.now() / 1000),
|
|
1478
|
+
model: mappedModel,
|
|
1479
|
+
choices: [
|
|
1480
|
+
{ index: 0, delta: {}, finish_reason: 'stop' }
|
|
1481
|
+
]
|
|
1482
|
+
});
|
|
1483
|
+
res.write('data: [DONE]\n\n');
|
|
1484
|
+
res.end();
|
|
1485
|
+
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
logError('Ошибка при обработке потокового запроса', error);
|
|
1488
|
+
writeSse({
|
|
1489
|
+
id: 'chatcmpl-stream',
|
|
1490
|
+
object: 'chat.completion.chunk',
|
|
1491
|
+
created: Math.floor(Date.now() / 1000),
|
|
1492
|
+
model: mappedModel,
|
|
1493
|
+
choices: [
|
|
1494
|
+
{ index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' }
|
|
1495
|
+
]
|
|
1496
|
+
});
|
|
1497
|
+
res.write('data: [DONE]\n\n');
|
|
1498
|
+
res.end();
|
|
1499
|
+
}
|
|
1500
|
+
} else {
|
|
1501
|
+
const combinedTools = tools || (functions ? functions.map(fn => ({ type: 'function', function: fn })) : null);
|
|
1502
|
+
const qwenChatId = await resolveQwenChatId(effectiveChatId, mappedModel);
|
|
1503
|
+
|
|
1504
|
+
const result = await sendMessage(messageContent, mappedModel, qwenChatId, effectiveParentId, filesForSend, combinedTools, tool_choice, systemMessage);
|
|
1505
|
+
|
|
1506
|
+
// Сохраняем chatId в сессии для следующих запросов
|
|
1507
|
+
if (!isMeta && result.chatId) {
|
|
1508
|
+
// Если мы использовали сгенерированный effectiveChatId — сохраните маппинг
|
|
1509
|
+
if (effectiveChatId && effectiveChatId.startsWith('chat_') && result.chatId) {
|
|
1510
|
+
mapChatId(effectiveChatId, result.chatId);
|
|
1511
|
+
logDebug(`Маппинг сохранён: ${effectiveChatId} -> ${result.chatId}`);
|
|
1512
|
+
}
|
|
1513
|
+
if (shouldPersistSessionContext(conversationScope)) {
|
|
1514
|
+
saveChatIdForSession(req, result.chatId, result.parentId, conversationScope);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
if (result.error) {
|
|
1519
|
+
return res.status(500).json({
|
|
1520
|
+
error: { message: result.error, type: "server_error" }
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Извлекаем контент сообщения
|
|
1525
|
+
let messageText = '';
|
|
1526
|
+
if (result.choices && result.choices[0] && result.choices[0].message) {
|
|
1527
|
+
messageText = result.choices[0].message.content || '';
|
|
1528
|
+
} else if (result.response && result.response.text) {
|
|
1529
|
+
messageText = result.response.text;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const openaiResponse = {
|
|
1533
|
+
id: result.id || "chatcmpl-" + Date.now(),
|
|
1534
|
+
object: "chat.completion",
|
|
1535
|
+
created: Math.floor(Date.now() / 1000),
|
|
1536
|
+
model: result.model || mappedModel,
|
|
1537
|
+
choices: [{
|
|
1538
|
+
index: 0,
|
|
1539
|
+
message: {
|
|
1540
|
+
role: "assistant",
|
|
1541
|
+
content: messageText
|
|
1542
|
+
},
|
|
1543
|
+
finish_reason: "stop"
|
|
1544
|
+
}],
|
|
1545
|
+
usage: result.usage || {
|
|
1546
|
+
prompt_tokens: 0,
|
|
1547
|
+
completion_tokens: 0,
|
|
1548
|
+
total_tokens: 0
|
|
1549
|
+
},
|
|
1550
|
+
// Передаём метаданные для сохранения контекста
|
|
1551
|
+
x_qwen_chat_id: result.chatId,
|
|
1552
|
+
x_qwen_parent_id: result.parentId || result.response_id
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
// Сохраняем историю чата для v1 эндпоинта
|
|
1556
|
+
if (result.chatId) {
|
|
1557
|
+
// Сохраняем chatId в сессии для последующих запросов от этого клиента
|
|
1558
|
+
if (!isMeta) {
|
|
1559
|
+
try {
|
|
1560
|
+
if (shouldPersistSessionContext(conversationScope)) {
|
|
1561
|
+
saveChatIdForSession(req, result.chatId, result.parentId || result.response_id, conversationScope);
|
|
1562
|
+
}
|
|
1563
|
+
} catch (e) {
|
|
1564
|
+
logDebug(`Не удалось сохранить chatId в сессии: ${e.message}`);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
try {
|
|
1569
|
+
const currentChat = loadHistory(result.chatId);
|
|
1570
|
+
const responseMessage = {
|
|
1571
|
+
role: 'assistant',
|
|
1572
|
+
content: messageText
|
|
1573
|
+
};
|
|
1574
|
+
const updatedMessages = messages.concat([responseMessage]);
|
|
1575
|
+
saveHistory(result.chatId, { ...currentChat, messages: updatedMessages });
|
|
1576
|
+
} catch (e) {
|
|
1577
|
+
logDebug(`Не удалось сохранить историю: ${e.message}`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
res.json(openaiResponse);
|
|
1582
|
+
}
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
logError('Ошибка при обработке v1 запроса', error);
|
|
1585
|
+
res.status(500).json({ error: { message: 'Внутренняя ошибка сервера', type: "server_error" } });
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
router.post('/files/getstsToken', async (req, res) => {
|
|
1590
|
+
try {
|
|
1591
|
+
logInfo(`Запрос на получение STS токена: ${JSON.stringify(req.body)}`);
|
|
1592
|
+
const fileInfo = req.body;
|
|
1593
|
+
if (!fileInfo?.filename || !fileInfo?.filesize || !fileInfo?.filetype) {
|
|
1594
|
+
logError('Некорректные данные о файле');
|
|
1595
|
+
return res.status(400).json({ error: 'Некорректные данные о файле' });
|
|
1596
|
+
}
|
|
1597
|
+
res.json(await getStsToken(fileInfo));
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
logError('Ошибка при получении STS токена', error);
|
|
1600
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
router.post('/files/upload', upload.single('file'), async (req, res) => {
|
|
1605
|
+
try {
|
|
1606
|
+
if (!req.file) { logError('Файл не был загружен'); return res.status(400).json({ error: 'Файл не был загружен' }); }
|
|
1607
|
+
logInfo(`Файл загружен на сервер: ${req.file.originalname} (${req.file.size} байт)`);
|
|
1608
|
+
|
|
1609
|
+
const result = await uploadFileToQwen(req.file.path);
|
|
1610
|
+
|
|
1611
|
+
try { fs.unlinkSync(req.file.path); } catch { /* file already removed or inaccessible */ }
|
|
1612
|
+
|
|
1613
|
+
if (result.success) {
|
|
1614
|
+
logInfo(`Файл успешно загружен в OSS: ${result.fileName}`);
|
|
1615
|
+
res.json({ success: true, file: { name: result.fileName, url: result.url, size: req.file.size, type: req.file.mimetype } });
|
|
1616
|
+
} else {
|
|
1617
|
+
logError(`Ошибка при загрузке файла в OSS: ${result.error}`);
|
|
1618
|
+
res.status(500).json({ error: 'Ошибка при загрузке файла' });
|
|
1619
|
+
}
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
logError('Ошибка при загрузке файла', error);
|
|
1622
|
+
if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } }
|
|
1623
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
// ─── Multipart Chat Endpoint (OpenAI-compatible with files) ─────────────────
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* POST /api/chat/multipart - Chat with file uploads via multipart/form-data
|
|
1631
|
+
* OpenAI-compatible endpoint that supports both JSON and file uploads
|
|
1632
|
+
*
|
|
1633
|
+
* Fields:
|
|
1634
|
+
* - message: Text message (required)
|
|
1635
|
+
* - model: Model name (optional)
|
|
1636
|
+
* - stream: Enable streaming (optional, boolean)
|
|
1637
|
+
* - files[]: Multiple files (optional, up to 5 files, max 10MB each)
|
|
1638
|
+
*/
|
|
1639
|
+
router.post('/chat/multipart', upload.array('files', 5), async (req, res) => {
|
|
1640
|
+
try {
|
|
1641
|
+
const { message, model, stream } = req.body;
|
|
1642
|
+
const uploadedFiles = req.files || [];
|
|
1643
|
+
|
|
1644
|
+
if (!message) {
|
|
1645
|
+
logError('Запрос без сообщения');
|
|
1646
|
+
return res.status(400).json({ error: 'Сообщение не указано' });
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
logInfo(`Получен multipart запрос: ${message.substring(0, 50)}${message.length > 50 ? '...' : ''}`);
|
|
1650
|
+
if (uploadedFiles.length > 0) {
|
|
1651
|
+
logInfo(`Прикреплено файлов: ${uploadedFiles.length}`);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
let mappedModel = model || getActiveModel();
|
|
1655
|
+
if (model) {
|
|
1656
|
+
mappedModel = getMappedModel(model);
|
|
1657
|
+
if (mappedModel !== model) {
|
|
1658
|
+
logInfo(`Модель "${model}" заменена на "${mappedModel}"`);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
logInfo(`Используется модель: ${mappedModel}`);
|
|
1662
|
+
|
|
1663
|
+
// Обрабатываем загруженные файлы
|
|
1664
|
+
const files = uploadedFiles.length > 0 ? await processFilesForQwen(uploadedFiles) : [];
|
|
1665
|
+
|
|
1666
|
+
// Встраиваем файлы в messageContent как в test 2
|
|
1667
|
+
let messageContent = message;
|
|
1668
|
+
if (files.length > 0) {
|
|
1669
|
+
messageContent = [
|
|
1670
|
+
{ type: 'text', text: message },
|
|
1671
|
+
...files
|
|
1672
|
+
];
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// Подготовка streaming если нужно
|
|
1676
|
+
if (stream === 'true' || stream === '1' || stream === true) {
|
|
1677
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1678
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1679
|
+
res.setHeader('Pragma', 'no-cache');
|
|
1680
|
+
res.setHeader('Expires', '0');
|
|
1681
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1682
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
1683
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
1684
|
+
|
|
1685
|
+
const writeSse = (payload) => {
|
|
1686
|
+
res.write('data: ' + JSON.stringify(payload) + '\n\n');
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
try {
|
|
1690
|
+
writeSse({
|
|
1691
|
+
id: 'chatcmpl-stream',
|
|
1692
|
+
object: 'chat.completion.chunk',
|
|
1693
|
+
created: Math.floor(Date.now() / 1000),
|
|
1694
|
+
model: mappedModel,
|
|
1695
|
+
choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }]
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
let streamingCallback = null;
|
|
1699
|
+
let hasStreamedChunks = false;
|
|
1700
|
+
streamingCallback = (chunk) => {
|
|
1701
|
+
hasStreamedChunks = true;
|
|
1702
|
+
writeSse({
|
|
1703
|
+
id: 'chatcmpl-stream',
|
|
1704
|
+
object: 'chat.completion.chunk',
|
|
1705
|
+
created: Math.floor(Date.now() / 1000),
|
|
1706
|
+
model: mappedModel,
|
|
1707
|
+
choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }]
|
|
1708
|
+
});
|
|
1709
|
+
};
|
|
1710
|
+
|
|
1711
|
+
const result = await sendMessage(
|
|
1712
|
+
messageContent,
|
|
1713
|
+
mappedModel,
|
|
1714
|
+
null, // chatId
|
|
1715
|
+
null, // parentId
|
|
1716
|
+
null, // files - теперь в messageContent
|
|
1717
|
+
null, // tools
|
|
1718
|
+
null, // toolChoice
|
|
1719
|
+
null, // systemMessage
|
|
1720
|
+
't2t',
|
|
1721
|
+
null,
|
|
1722
|
+
true,
|
|
1723
|
+
0,
|
|
1724
|
+
streamingCallback
|
|
1725
|
+
);
|
|
1726
|
+
|
|
1727
|
+
if (result.error) {
|
|
1728
|
+
writeSse({
|
|
1729
|
+
id: 'chatcmpl-stream',
|
|
1730
|
+
object: 'chat.completion.chunk',
|
|
1731
|
+
created: Math.floor(Date.now() / 1000),
|
|
1732
|
+
model: mappedModel,
|
|
1733
|
+
choices: [{ index: 0, delta: { content: `Error: ${result.error}` }, finish_reason: 'stop' }]
|
|
1734
|
+
});
|
|
1735
|
+
} else if (!hasStreamedChunks && result.choices?.[0]?.message?.content) {
|
|
1736
|
+
const content = result.choices[0].message.content;
|
|
1737
|
+
if (typeof streamingCallback === 'function') {
|
|
1738
|
+
streamingCallback(content);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
writeSse({
|
|
1743
|
+
id: 'chatcmpl-stream',
|
|
1744
|
+
object: 'chat.completion.chunk',
|
|
1745
|
+
created: Math.floor(Date.now() / 1000),
|
|
1746
|
+
model: mappedModel,
|
|
1747
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }]
|
|
1748
|
+
});
|
|
1749
|
+
res.write('data: [DONE]\n\n');
|
|
1750
|
+
res.end();
|
|
1751
|
+
return;
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
logError('Ошибка при обработке потокового multipart запроса', error);
|
|
1754
|
+
writeSse({
|
|
1755
|
+
id: 'chatcmpl-stream',
|
|
1756
|
+
object: 'chat.completion.chunk',
|
|
1757
|
+
created: Math.floor(Date.now() / 1000),
|
|
1758
|
+
model: mappedModel,
|
|
1759
|
+
choices: [{ index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' }]
|
|
1760
|
+
});
|
|
1761
|
+
res.write('data: [DONE]\n\n');
|
|
1762
|
+
res.end();
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
} else {
|
|
1766
|
+
// Non-streaming response
|
|
1767
|
+
const result = await sendMessage(
|
|
1768
|
+
messageContent,
|
|
1769
|
+
mappedModel,
|
|
1770
|
+
null,
|
|
1771
|
+
null,
|
|
1772
|
+
null, // files - теперь в messageContent
|
|
1773
|
+
null,
|
|
1774
|
+
null,
|
|
1775
|
+
null,
|
|
1776
|
+
't2t',
|
|
1777
|
+
null,
|
|
1778
|
+
true
|
|
1779
|
+
);
|
|
1780
|
+
|
|
1781
|
+
if (result.error) {
|
|
1782
|
+
return res.status(500).json({
|
|
1783
|
+
error: { message: result.error, type: 'server_error' }
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const openaiResponse = {
|
|
1788
|
+
id: result.id || 'chatcmpl-' + Date.now(),
|
|
1789
|
+
object: 'chat.completion',
|
|
1790
|
+
created: Math.floor(Date.now() / 1000),
|
|
1791
|
+
model: result.model || mappedModel,
|
|
1792
|
+
choices: result.choices || [{
|
|
1793
|
+
index: 0,
|
|
1794
|
+
message: {
|
|
1795
|
+
role: 'assistant',
|
|
1796
|
+
content: result.choices?.[0]?.message?.content || ''
|
|
1797
|
+
},
|
|
1798
|
+
finish_reason: 'stop'
|
|
1799
|
+
}],
|
|
1800
|
+
usage: result.usage || {
|
|
1801
|
+
prompt_tokens: 0,
|
|
1802
|
+
completion_tokens: 0,
|
|
1803
|
+
total_tokens: 0
|
|
1804
|
+
},
|
|
1805
|
+
chatId: result.chatId,
|
|
1806
|
+
parentId: result.parentId
|
|
1807
|
+
};
|
|
1808
|
+
|
|
1809
|
+
res.json(openaiResponse);
|
|
1810
|
+
}
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
logError('Ошибка при обработке multipart запроса', error);
|
|
1813
|
+
res.status(500).json({ error: { message: 'Внутренняя ошибка сервера', type: 'server_error' } });
|
|
1814
|
+
}
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
// Эндпоинт для сохранения истории чата (для работы с Open WebUI)
|
|
1818
|
+
router.post('/chats/:chatId/history', async (req, res) => {
|
|
1819
|
+
try {
|
|
1820
|
+
const { chatId } = req.params;
|
|
1821
|
+
const { messages } = req.body;
|
|
1822
|
+
|
|
1823
|
+
logInfo(`Запрос сохранения истории для чата: ${chatId}`);
|
|
1824
|
+
|
|
1825
|
+
if (!messages || !Array.isArray(messages)) {
|
|
1826
|
+
logError('История сообщений не указана или некорректна');
|
|
1827
|
+
return res.status(400).json({ error: 'История сообщений должна быть массивом' });
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Здесь можно добавить логику сохранения истории
|
|
1831
|
+
// Для теперь просто подтверждаем сохранение
|
|
1832
|
+
res.json({
|
|
1833
|
+
success: true,
|
|
1834
|
+
chatId: chatId,
|
|
1835
|
+
messagesCount: messages.length
|
|
1836
|
+
});
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
logError('Ошибка при сохранении истории чата', error);
|
|
1839
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
1840
|
+
}
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
// Эндпоинт для получения истории чата (для работы с Open WebUI)
|
|
1844
|
+
router.get('/chats/:chatId/history', async (req, res) => {
|
|
1845
|
+
try {
|
|
1846
|
+
const { chatId } = req.params;
|
|
1847
|
+
|
|
1848
|
+
logInfo(`Запрос истории для чата: ${chatId}`);
|
|
1849
|
+
|
|
1850
|
+
// Здесь можно добавить логику получения истории из БД
|
|
1851
|
+
// Для теперь возвращаем пустую историю
|
|
1852
|
+
res.json({
|
|
1853
|
+
success: true,
|
|
1854
|
+
chatId: chatId,
|
|
1855
|
+
messages: []
|
|
1856
|
+
});
|
|
1857
|
+
} catch (error) {
|
|
1858
|
+
logError('Ошибка при получении истории чата', error);
|
|
1859
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
// ============================================
|
|
1864
|
+
// ЭНДПОИНТЫ ДЛЯ ГЕНЕРАЦИИ ИЗОБРАЖЕНИЙ
|
|
1865
|
+
// ============================================
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* POST /api/images/generations - Генерация изображений по тексту (OpenAI DALL-E совместимый)
|
|
1869
|
+
* Формат запроса совместим с OpenAI Images API
|
|
1870
|
+
*/
|
|
1871
|
+
router.post('/images/generations', async (req, res) => {
|
|
1872
|
+
try {
|
|
1873
|
+
const { prompt, model, n, size, response_format, user } = req.body;
|
|
1874
|
+
|
|
1875
|
+
logInfo(`Получен запрос на генерацию изображения`);
|
|
1876
|
+
logDebug(`Prompt: ${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}`);
|
|
1877
|
+
|
|
1878
|
+
if (!prompt) {
|
|
1879
|
+
return res.status(400).json({ error: 'Параметр "prompt" обязателен' });
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// Маппинг моделей OpenAI на Qwen Image модели
|
|
1883
|
+
let imageModel = model || 'qwen-image-plus';
|
|
1884
|
+
if (imageModel === 'dall-e-3' || imageModel === 'dall-e-2') {
|
|
1885
|
+
imageModel = 'qwen-image-plus';
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Проверка доступности API
|
|
1889
|
+
if (IMAGE_GENERATION_MODE === 'dashscope' && !DASHSCOPE_API_KEY) {
|
|
1890
|
+
return res.status(503).json({
|
|
1891
|
+
error: 'API генерации изображений не настроен',
|
|
1892
|
+
message: 'Установите переменную окружения DASHSCOPE_API_KEY или переключитесь на IMAGE_GENERATION_MODE=browser'
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Преобразование размера из формата OpenAI в формат Qwen
|
|
1897
|
+
let qwenSize = '1024*1024';
|
|
1898
|
+
if (size) {
|
|
1899
|
+
const sizeMap = {
|
|
1900
|
+
'1024x1024': '1024*1024',
|
|
1901
|
+
'1024x1792': '1024*1792',
|
|
1902
|
+
'1792x1024': '1792*1024',
|
|
1903
|
+
'512x512': '512*512',
|
|
1904
|
+
'768x768': '768*768',
|
|
1905
|
+
'960x960': '960*960'
|
|
1906
|
+
};
|
|
1907
|
+
qwenSize = sizeMap[size] || '1024*1024';
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const result = await generateImage(prompt, imageModel, {
|
|
1911
|
+
n: n || 1,
|
|
1912
|
+
size: qwenSize,
|
|
1913
|
+
promptExtend: true,
|
|
1914
|
+
watermark: false
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
if (result.error) {
|
|
1918
|
+
logError(`Ошибка генерации: ${result.error}`);
|
|
1919
|
+
return res.status(500).json({
|
|
1920
|
+
error: 'Ошибка генерации изображения',
|
|
1921
|
+
message: result.error
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// Формируем ответ в формате OpenAI Images API
|
|
1926
|
+
const responseData = {
|
|
1927
|
+
created: Math.floor(Date.now() / 1000),
|
|
1928
|
+
data: [{
|
|
1929
|
+
url: result.imageUrl,
|
|
1930
|
+
revised_prompt: prompt
|
|
1931
|
+
}]
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
logInfo(`Изображение сгенерировано: ${result.imageUrl}`);
|
|
1935
|
+
res.json(responseData);
|
|
1936
|
+
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
logError('Ошибка при генерации изображения', error);
|
|
1939
|
+
res.status(500).json({
|
|
1940
|
+
error: 'Внутренняя ошибка сервера',
|
|
1941
|
+
message: error.message
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
/**
|
|
1947
|
+
* GET /api/images/models - Получение списка моделей генерации изображений
|
|
1948
|
+
*/
|
|
1949
|
+
router.get('/images/models', async (req, res) => {
|
|
1950
|
+
try {
|
|
1951
|
+
const models = getAvailableImageModels();
|
|
1952
|
+
|
|
1953
|
+
res.json({
|
|
1954
|
+
object: 'list',
|
|
1955
|
+
data: models.map(model => ({
|
|
1956
|
+
id: model,
|
|
1957
|
+
object: 'model',
|
|
1958
|
+
created: Date.now(),
|
|
1959
|
+
owned_by: 'qwen',
|
|
1960
|
+
permission: [],
|
|
1961
|
+
capability: 'image_generation'
|
|
1962
|
+
}))
|
|
1963
|
+
});
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
logError('Ошибка при получении списка моделей изображений', error);
|
|
1966
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* GET /api/images/status - Проверка статуса API генерации изображений
|
|
1972
|
+
*/
|
|
1973
|
+
router.get('/images/status', async (req, res) => {
|
|
1974
|
+
try {
|
|
1975
|
+
const isAvailable = await checkImageApiAvailability();
|
|
1976
|
+
|
|
1977
|
+
res.json({
|
|
1978
|
+
available: isAvailable,
|
|
1979
|
+
mode: IMAGE_GENERATION_MODE,
|
|
1980
|
+
apiKeyConfigured: !!DASHSCOPE_API_KEY,
|
|
1981
|
+
message: IMAGE_GENERATION_MODE === 'browser'
|
|
1982
|
+
? (isAvailable ? 'Browser mode активен' : 'Браузер не инициализирован')
|
|
1983
|
+
: (isAvailable
|
|
1984
|
+
? 'DashScope API доступен'
|
|
1985
|
+
: DASHSCOPE_API_KEY
|
|
1986
|
+
? 'DashScope API недоступен или неверные учётные данные'
|
|
1987
|
+
: 'API ключ DASHSCOPE_API_KEY не настроен')
|
|
1988
|
+
});
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
logError('Ошибка при проверке статуса API изображений', error);
|
|
1991
|
+
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
// ============================================
|
|
1996
|
+
// OPENAI-COMPATIBLE V1 ENDPOINTS
|
|
1997
|
+
// ============================================
|
|
1998
|
+
|
|
1999
|
+
/**
|
|
2000
|
+
* GET /v1/images/generations - OpenAI-compatible image generation
|
|
2001
|
+
* Полная совместимость с openai-node и openai-python SDK
|
|
2002
|
+
*/
|
|
2003
|
+
router.post('/v1/images/generations', async (req, res) => {
|
|
2004
|
+
try {
|
|
2005
|
+
const { prompt, model, n, size, response_format, quality, style, user } = req.body;
|
|
2006
|
+
|
|
2007
|
+
logInfo(`[OpenAI v1] Получен запрос на генерацию изображения`);
|
|
2008
|
+
logDebug(`Prompt: ${prompt?.substring(0, 100)}${prompt?.length > 100 ? '...' : ''}`);
|
|
2009
|
+
|
|
2010
|
+
if (!prompt) {
|
|
2011
|
+
return res.status(400).json({
|
|
2012
|
+
error: {
|
|
2013
|
+
message: 'Parameter "prompt" is required',
|
|
2014
|
+
type: 'invalid_request_error',
|
|
2015
|
+
param: 'prompt',
|
|
2016
|
+
code: null
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Маппинг моделей OpenAI на Qwen Image модели
|
|
2022
|
+
let imageModel = model || 'qwen-image-plus';
|
|
2023
|
+
const modelMapping = {
|
|
2024
|
+
'dall-e-3': 'qwen-image-max',
|
|
2025
|
+
'dall-e-2': 'qwen-image-plus',
|
|
2026
|
+
'qwen-image-max': 'qwen-image-max',
|
|
2027
|
+
'qwen-image-plus': 'qwen-image-plus',
|
|
2028
|
+
'qwen-image': 'qwen-image',
|
|
2029
|
+
'wan2.6-t2i': 'wan2.6-t2i',
|
|
2030
|
+
'wan2.5-t2i-preview': 'wan2.5-t2i-preview',
|
|
2031
|
+
'wan2.2-t2i-flash': 'wan2.2-t2i-flash'
|
|
2032
|
+
};
|
|
2033
|
+
imageModel = modelMapping[model] || 'qwen-image-plus';
|
|
2034
|
+
|
|
2035
|
+
// Проверка доступности API
|
|
2036
|
+
if (IMAGE_GENERATION_MODE === 'dashscope' && !DASHSCOPE_API_KEY) {
|
|
2037
|
+
return res.status(503).json({
|
|
2038
|
+
error: {
|
|
2039
|
+
message: 'Image generation API is not configured',
|
|
2040
|
+
type: 'server_error',
|
|
2041
|
+
param: null,
|
|
2042
|
+
code: 'service_unavailable'
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// Преобразование размера из формата OpenAI в формат Qwen
|
|
2048
|
+
let qwenSize = '1024*1024';
|
|
2049
|
+
if (size) {
|
|
2050
|
+
const sizeMap = {
|
|
2051
|
+
'1024x1024': '1024*1024',
|
|
2052
|
+
'1024x1792': '1024*1792',
|
|
2053
|
+
'1792x1024': '1792*1024',
|
|
2054
|
+
'512x512': '512*512',
|
|
2055
|
+
'768x768': '768x768',
|
|
2056
|
+
'960x960': '960*960'
|
|
2057
|
+
};
|
|
2058
|
+
qwenSize = sizeMap[size] || '1024*1024';
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const result = await generateImage(prompt, imageModel, {
|
|
2062
|
+
n: n || 1,
|
|
2063
|
+
size: qwenSize,
|
|
2064
|
+
promptExtend: true,
|
|
2065
|
+
watermark: false
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
if (result.error) {
|
|
2069
|
+
logError(`Ошибка генерации: ${result.error}`);
|
|
2070
|
+
return res.status(500).json({
|
|
2071
|
+
error: {
|
|
2072
|
+
message: `Image generation failed: ${result.error}`,
|
|
2073
|
+
type: 'server_error',
|
|
2074
|
+
param: null,
|
|
2075
|
+
code: 'generation_failed'
|
|
2076
|
+
}
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// Формируем ответ в формате OpenAI Images API
|
|
2081
|
+
const responseData = {
|
|
2082
|
+
created: Math.floor(Date.now() / 1000),
|
|
2083
|
+
data: [{
|
|
2084
|
+
url: result.imageUrl,
|
|
2085
|
+
revised_prompt: prompt
|
|
2086
|
+
}]
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
// Если запрошено несколько изображений (когда API поддерживает)
|
|
2090
|
+
if (n > 1 && result.imageUrls) {
|
|
2091
|
+
responseData.data = result.imageUrls.map(url => ({
|
|
2092
|
+
url,
|
|
2093
|
+
revised_prompt: prompt
|
|
2094
|
+
}));
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
logInfo(`[OpenAI v1] Изображение сгенерировано: ${result.imageUrl}`);
|
|
2098
|
+
res.json(responseData);
|
|
2099
|
+
|
|
2100
|
+
} catch (error) {
|
|
2101
|
+
logError('[OpenAI v1] Ошибка при генерации изображения', error);
|
|
2102
|
+
res.status(500).json({
|
|
2103
|
+
error: {
|
|
2104
|
+
message: `Internal server error: ${error.message}`,
|
|
2105
|
+
type: 'server_error',
|
|
2106
|
+
param: null,
|
|
2107
|
+
code: null
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* GET /v1/models - OpenAI-compatible models list (включая image models)
|
|
2115
|
+
*/
|
|
2116
|
+
router.get('/v1/models', async (req, res) => {
|
|
2117
|
+
try {
|
|
2118
|
+
const chatModels = getAllModels();
|
|
2119
|
+
const imageModels = getAvailableImageModels();
|
|
2120
|
+
|
|
2121
|
+
const allModels = {
|
|
2122
|
+
object: 'list',
|
|
2123
|
+
data: [
|
|
2124
|
+
// Chat models
|
|
2125
|
+
...chatModels.models.map(m => ({
|
|
2126
|
+
id: m.id || m.name || m,
|
|
2127
|
+
object: 'model',
|
|
2128
|
+
created: 0,
|
|
2129
|
+
owned_by: 'qwen',
|
|
2130
|
+
permission: [],
|
|
2131
|
+
capabilities: ['chat', 'completion']
|
|
2132
|
+
})),
|
|
2133
|
+
// Image generation models
|
|
2134
|
+
...imageModels.map(model => ({
|
|
2135
|
+
id: model,
|
|
2136
|
+
object: 'model',
|
|
2137
|
+
created: Date.now(),
|
|
2138
|
+
owned_by: 'qwen',
|
|
2139
|
+
permission: [],
|
|
2140
|
+
capabilities: ['image_generation']
|
|
2141
|
+
}))
|
|
2142
|
+
]
|
|
2143
|
+
};
|
|
2144
|
+
|
|
2145
|
+
logInfo(`[OpenAI v1] Возвращено ${allModels.data.length} моделей`);
|
|
2146
|
+
res.json(allModels);
|
|
2147
|
+
} catch (error) {
|
|
2148
|
+
logError('[OpenAI v1] Ошибка при получении списка моделей', error);
|
|
2149
|
+
res.status(500).json({
|
|
2150
|
+
error: {
|
|
2151
|
+
message: 'Internal server error',
|
|
2152
|
+
type: 'server_error',
|
|
2153
|
+
param: null,
|
|
2154
|
+
code: null
|
|
2155
|
+
}
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
export default router;
|