qwenproxy-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. package/src/utils/types.ts +109 -0
@@ -0,0 +1,731 @@
1
+ import type { Context } from "hono";
2
+ import { Readable } from "stream";
3
+ import {
4
+ getBasicHeaders,
5
+ isAuthMockEnabled,
6
+ } from "../services/auth-playwright.ts";
7
+ import { v4 as uuidv4 } from "uuid";
8
+ import { ValidationError, ServiceUnavailable } from "../core/errors.js";
9
+ import { sendOpenAIError } from "../api/error-helpers.js";
10
+ import { buildQwenRequestHeaders } from "../services/qwen-headers.ts";
11
+ import { qwenUrl } from "../services/qwen-url.ts";
12
+ import { config } from "../core/config.ts";
13
+
14
+ // Cache the heavy ali-oss module so we import it once, not on every upload.
15
+ let cachedOSSModule: any = null;
16
+ async function getOSSModule() {
17
+ if (!cachedOSSModule) {
18
+ cachedOSSModule = (await import("ali-oss")).default;
19
+ }
20
+ return cachedOSSModule;
21
+ }
22
+
23
+ interface STSResponse {
24
+ success: boolean;
25
+ request_id: string;
26
+ data: {
27
+ access_key_id: string;
28
+ access_key_secret: string;
29
+ security_token: string;
30
+ file_url: string;
31
+ file_path: string;
32
+ file_id: string;
33
+ bucketname: string;
34
+ region: string;
35
+ endpoint: string;
36
+ };
37
+ }
38
+
39
+ interface FileTypeInfo {
40
+ mime: string;
41
+ showType: "image" | "video" | "audio" | "file";
42
+ fileClass: "vision" | "video" | "audio" | "file";
43
+ qwenFileType: "image" | "video" | "audio" | "file";
44
+ }
45
+
46
+ const DEFAULT_FILE_TYPE_INFO: FileTypeInfo = {
47
+ mime: "application/octet-stream",
48
+ showType: "file",
49
+ fileClass: "file",
50
+ qwenFileType: "file",
51
+ };
52
+
53
+ const FILE_TYPE_MAP: Record<string, FileTypeInfo> = {
54
+ png: {
55
+ mime: "image/png",
56
+ showType: "image",
57
+ fileClass: "vision",
58
+ qwenFileType: "image",
59
+ },
60
+ jpg: {
61
+ mime: "image/jpeg",
62
+ showType: "image",
63
+ fileClass: "vision",
64
+ qwenFileType: "image",
65
+ },
66
+ jpeg: {
67
+ mime: "image/jpeg",
68
+ showType: "image",
69
+ fileClass: "vision",
70
+ qwenFileType: "image",
71
+ },
72
+ gif: {
73
+ mime: "image/gif",
74
+ showType: "image",
75
+ fileClass: "vision",
76
+ qwenFileType: "image",
77
+ },
78
+ webp: {
79
+ mime: "image/webp",
80
+ showType: "image",
81
+ fileClass: "vision",
82
+ qwenFileType: "image",
83
+ },
84
+ mp4: {
85
+ mime: "video/mp4",
86
+ showType: "video",
87
+ fileClass: "video",
88
+ qwenFileType: "video",
89
+ },
90
+ mov: {
91
+ mime: "video/quicktime",
92
+ showType: "video",
93
+ fileClass: "video",
94
+ qwenFileType: "video",
95
+ },
96
+ avi: {
97
+ mime: "video/x-msvideo",
98
+ showType: "video",
99
+ fileClass: "video",
100
+ qwenFileType: "video",
101
+ },
102
+ webm: {
103
+ mime: "video/webm",
104
+ showType: "video",
105
+ fileClass: "video",
106
+ qwenFileType: "video",
107
+ },
108
+ mkv: {
109
+ mime: "video/x-matroska",
110
+ showType: "video",
111
+ fileClass: "video",
112
+ qwenFileType: "video",
113
+ },
114
+ mp3: {
115
+ mime: "audio/mpeg",
116
+ showType: "audio",
117
+ fileClass: "audio",
118
+ qwenFileType: "audio",
119
+ },
120
+ wav: {
121
+ mime: "audio/wav",
122
+ showType: "audio",
123
+ fileClass: "audio",
124
+ qwenFileType: "audio",
125
+ },
126
+ ogg: {
127
+ mime: "audio/ogg",
128
+ showType: "audio",
129
+ fileClass: "audio",
130
+ qwenFileType: "audio",
131
+ },
132
+ flac: {
133
+ mime: "audio/flac",
134
+ showType: "audio",
135
+ fileClass: "audio",
136
+ qwenFileType: "audio",
137
+ },
138
+ m4a: {
139
+ mime: "audio/mp4",
140
+ showType: "audio",
141
+ fileClass: "audio",
142
+ qwenFileType: "audio",
143
+ },
144
+ aac: {
145
+ mime: "audio/aac",
146
+ showType: "audio",
147
+ fileClass: "audio",
148
+ qwenFileType: "audio",
149
+ },
150
+ pdf: {
151
+ mime: "application/pdf",
152
+ showType: "file",
153
+ fileClass: "file",
154
+ qwenFileType: "file",
155
+ },
156
+ doc: {
157
+ mime: "application/msword",
158
+ showType: "file",
159
+ fileClass: "file",
160
+ qwenFileType: "file",
161
+ },
162
+ docx: {
163
+ mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
164
+ showType: "file",
165
+ fileClass: "file",
166
+ qwenFileType: "file",
167
+ },
168
+ xls: {
169
+ mime: "application/vnd.ms-excel",
170
+ showType: "file",
171
+ fileClass: "file",
172
+ qwenFileType: "file",
173
+ },
174
+ xlsx: {
175
+ mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
176
+ showType: "file",
177
+ fileClass: "file",
178
+ qwenFileType: "file",
179
+ },
180
+ ppt: {
181
+ mime: "application/vnd.ms-powerpoint",
182
+ showType: "file",
183
+ fileClass: "file",
184
+ qwenFileType: "file",
185
+ },
186
+ pptx: {
187
+ mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
188
+ showType: "file",
189
+ fileClass: "file",
190
+ qwenFileType: "file",
191
+ },
192
+ txt: {
193
+ mime: "text/plain",
194
+ showType: "file",
195
+ fileClass: "file",
196
+ qwenFileType: "file",
197
+ },
198
+ md: {
199
+ mime: "text/markdown",
200
+ showType: "file",
201
+ fileClass: "file",
202
+ qwenFileType: "file",
203
+ },
204
+ csv: {
205
+ mime: "text/csv",
206
+ showType: "file",
207
+ fileClass: "file",
208
+ qwenFileType: "file",
209
+ },
210
+ json: {
211
+ mime: "application/json",
212
+ showType: "file",
213
+ fileClass: "file",
214
+ qwenFileType: "file",
215
+ },
216
+ xml: {
217
+ mime: "application/xml",
218
+ showType: "file",
219
+ fileClass: "file",
220
+ qwenFileType: "file",
221
+ },
222
+ html: {
223
+ mime: "text/html",
224
+ showType: "file",
225
+ fileClass: "file",
226
+ qwenFileType: "file",
227
+ },
228
+ zip: {
229
+ mime: "application/zip",
230
+ showType: "file",
231
+ fileClass: "file",
232
+ qwenFileType: "file",
233
+ },
234
+ };
235
+
236
+ const SUPPORTED_MIME_TYPES = new Set(
237
+ Object.values(FILE_TYPE_MAP).map((typeInfo) => typeInfo.mime),
238
+ );
239
+
240
+ function getFileExtension(filename: string): string {
241
+ return filename.split(".").pop()?.toLowerCase() || "";
242
+ }
243
+
244
+ function detectFileType(filename: string): FileTypeInfo {
245
+ return FILE_TYPE_MAP[getFileExtension(filename)] || DEFAULT_FILE_TYPE_INFO;
246
+ }
247
+
248
+ function getExtensionFromMime(mime: string): string | undefined {
249
+ for (const [ext, typeInfo] of Object.entries(FILE_TYPE_MAP)) {
250
+ if (typeInfo.mime === mime) {
251
+ return ext;
252
+ }
253
+ }
254
+ return undefined;
255
+ }
256
+
257
+ function getMaxUploadSize(fileType: string): number {
258
+ if (fileType.startsWith("video/")) return 100 * 1024 * 1024;
259
+ if (fileType.startsWith("audio/")) return 50 * 1024 * 1024;
260
+ return 20 * 1024 * 1024;
261
+ }
262
+
263
+ function getFilenameFromUrl(url: string, mime?: string): string {
264
+ let filename = "";
265
+
266
+ try {
267
+ filename = decodeURIComponent(new URL(url).pathname.split("/").pop() || "");
268
+ } catch {
269
+ filename = url.split("/").pop()?.split("?")[0] || "";
270
+ }
271
+
272
+ if (!filename) {
273
+ filename = "file";
274
+ }
275
+
276
+ if (!filename.includes(".")) {
277
+ const ext = mime ? getExtensionFromMime(mime) : undefined;
278
+ if (ext) {
279
+ filename = `${filename}.${ext}`;
280
+ }
281
+ }
282
+
283
+ return filename || "file.bin";
284
+ }
285
+
286
+ async function downloadRemoteMedia(url: string): Promise<{
287
+ buffer: Buffer;
288
+ filename: string;
289
+ mime: string;
290
+ }> {
291
+ const response = await fetch(url, {
292
+ headers: {
293
+ "User-Agent": config.auth.userAgent,
294
+ Accept: "image/*,*/*;q=0.8",
295
+ },
296
+ });
297
+ if (!response.ok) {
298
+ throw new Error(`Remote media download failed: ${response.status}`);
299
+ }
300
+
301
+ const headerMime =
302
+ response.headers.get("content-type")?.split(";")[0].trim() || "";
303
+ const filename = getFilenameFromUrl(url, headerMime || undefined);
304
+ const detectedMime =
305
+ headerMime && SUPPORTED_MIME_TYPES.has(headerMime)
306
+ ? headerMime
307
+ : detectFileType(filename).mime;
308
+
309
+ if (!SUPPORTED_MIME_TYPES.has(detectedMime)) {
310
+ throw new Error(
311
+ `Unsupported remote media type: ${headerMime || detectedMime || "unknown"}`,
312
+ );
313
+ }
314
+
315
+ const buffer = Buffer.from(await response.arrayBuffer());
316
+ const maxSize = getMaxUploadSize(detectedMime);
317
+ if (buffer.length > maxSize) {
318
+ throw new Error(`Remote media too large: ${buffer.length}`);
319
+ }
320
+
321
+ return {
322
+ buffer,
323
+ filename,
324
+ mime: detectedMime,
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Get STS token from Qwen for file upload
330
+ */
331
+ async function getSTSToken(
332
+ filename: string,
333
+ filesize: number,
334
+ filetype: string,
335
+ headers: Record<string, string>,
336
+ ): Promise<STSResponse["data"]> {
337
+ const response = await fetch(
338
+ qwenUrl("/api/v2/files/getstsToken"),
339
+ {
340
+ method: "POST",
341
+ headers: buildQwenRequestHeaders({
342
+ cookie: headers.cookie,
343
+ userAgent: headers["user-agent"],
344
+ bxUa: headers["bx-ua"],
345
+ bxUmidtoken: headers["bx-umidtoken"],
346
+ bxV: headers["bx-v"],
347
+ }),
348
+ body: JSON.stringify({ filename, filesize: String(filesize), filetype }),
349
+ },
350
+ );
351
+
352
+ if (!response.ok) {
353
+ const errorText = await response.text().catch(() => "");
354
+ throw new Error(
355
+ `STS token request failed: ${response.status} ${errorText.substring(0, 200)}`,
356
+ );
357
+ }
358
+
359
+ const data = await response.json();
360
+ if (!data.success || !data.data) {
361
+ throw new Error(
362
+ `STS token invalid: ${JSON.stringify(data).substring(0, 200)}`,
363
+ );
364
+ }
365
+
366
+ return data.data;
367
+ }
368
+
369
+ /** Minimal OSS client surface used by upload helpers (easy to mock in tests). */
370
+ export interface OssUploadClient {
371
+ putStream: (
372
+ name: string,
373
+ stream: Readable,
374
+ options?: {
375
+ contentLength?: number;
376
+ headers?: Record<string, string>;
377
+ },
378
+ ) => Promise<unknown>;
379
+ multipartUpload: (
380
+ name: string,
381
+ file: Buffer | string,
382
+ options?: {
383
+ partSize?: number;
384
+ headers?: Record<string, string>;
385
+ },
386
+ ) => Promise<unknown>;
387
+ }
388
+
389
+ /**
390
+ * Stream-friendly OSS body upload:
391
+ * - small/medium: putStream (avoids ali-oss put() extra buffering path)
392
+ * - large: multipartUpload (better reliability for big media)
393
+ */
394
+ export async function uploadBufferToOssClient(
395
+ client: OssUploadClient,
396
+ filePath: string,
397
+ buffer: Buffer,
398
+ contentType: string,
399
+ multipartThresholdBytes: number = config.oss.multipartThresholdBytes,
400
+ ): Promise<"putStream" | "multipart"> {
401
+ const headers = { "Content-Type": contentType };
402
+
403
+ if (buffer.length >= multipartThresholdBytes) {
404
+ const partSize = Math.min(
405
+ 5 * 1024 * 1024,
406
+ Math.max(256 * 1024, Math.floor(buffer.length / 4)),
407
+ );
408
+ await client.multipartUpload(filePath, buffer, {
409
+ partSize,
410
+ headers,
411
+ });
412
+ return "multipart";
413
+ }
414
+
415
+ await client.putStream(filePath, Readable.from(buffer), {
416
+ contentLength: buffer.length,
417
+ headers,
418
+ });
419
+ return "putStream";
420
+ }
421
+
422
+ /**
423
+ * Upload file to Alibaba Cloud OSS using STS credentials
424
+ */
425
+ async function uploadToOSS(
426
+ fileBuffer: ArrayBuffer | Buffer,
427
+ stsData: STSResponse["data"],
428
+ filename: string,
429
+ ): Promise<string> {
430
+ if (isAuthMockEnabled()) {
431
+ return stsData.file_url.split("?")[0];
432
+ }
433
+ const {
434
+ access_key_id,
435
+ access_key_secret,
436
+ security_token,
437
+ file_url,
438
+ file_path,
439
+ bucketname,
440
+ region,
441
+ endpoint,
442
+ } = stsData;
443
+
444
+ const OSS = await getOSSModule();
445
+ const client = new OSS({
446
+ region,
447
+ accessKeyId: access_key_id,
448
+ accessKeySecret: access_key_secret,
449
+ stsToken: security_token,
450
+ bucket: bucketname,
451
+ endpoint: `https://${endpoint}`,
452
+ secure: true,
453
+ refreshSTSToken: async () => ({
454
+ accessKeyId: access_key_id,
455
+ accessKeySecret: access_key_secret,
456
+ stsToken: security_token,
457
+ }),
458
+ refreshSTSTokenInterval: 300000,
459
+ }) as unknown as OssUploadClient;
460
+
461
+ const buffer = Buffer.isBuffer(fileBuffer)
462
+ ? fileBuffer
463
+ : Buffer.from(fileBuffer);
464
+ const contentType = detectFileType(filename).mime;
465
+
466
+ await uploadBufferToOssClient(client, file_path, buffer, contentType);
467
+
468
+ return file_url.split("?")[0];
469
+ }
470
+
471
+ /**
472
+ * Handle image upload endpoint
473
+ * POST /v1/upload
474
+ */
475
+ export async function uploadFile(c: Context) {
476
+ try {
477
+ const formData = await c.req.formData();
478
+ const file = formData.get("file") as File | null;
479
+
480
+ if (!file) {
481
+ return sendOpenAIError(c, new ValidationError("No file provided"));
482
+ }
483
+
484
+ // Detect MIME from filename if the client sends a generic type
485
+ let fileType = file.type;
486
+ if (fileType === "application/octet-stream" || !fileType) {
487
+ fileType = detectFileType(file.name).mime;
488
+ }
489
+
490
+ // Validate file type is supported by Qwen
491
+ if (!SUPPORTED_MIME_TYPES.has(fileType)) {
492
+ return sendOpenAIError(
493
+ c,
494
+ new ValidationError(
495
+ `Unsupported file type: ${file.type || "unknown"}. Supported: images, videos, audio, documents (PDF, DOC, XLS, PPT, TXT, MD, CSV, JSON, XML, HTML, ZIP)`,
496
+ ),
497
+ );
498
+ }
499
+
500
+ // Determine media category for size limits
501
+ const isVideo = fileType.startsWith("video/");
502
+ const isAudio = fileType.startsWith("audio/");
503
+ const maxSize = getMaxUploadSize(fileType);
504
+ if (file.size > maxSize) {
505
+ const sizeLabel = isVideo
506
+ ? "100MB (video)"
507
+ : isAudio
508
+ ? "50MB (audio)"
509
+ : "20MB (image/doc)";
510
+ return sendOpenAIError(
511
+ c,
512
+ new ValidationError(`File too large. Max size: ${sizeLabel}`),
513
+ );
514
+ }
515
+
516
+ let headers: Record<string, string>;
517
+ try {
518
+ const { cookie, userAgent, bxV, bxUa, bxUmidtoken } =
519
+ await getBasicHeaders();
520
+ headers = {
521
+ cookie,
522
+ "user-agent": userAgent,
523
+ "bx-v": bxV,
524
+ };
525
+ if (bxUa) headers["bx-ua"] = bxUa;
526
+ if (bxUmidtoken) headers["bx-umidtoken"] = bxUmidtoken;
527
+ } catch (error) {
528
+ return sendOpenAIError(
529
+ c,
530
+ new ServiceUnavailable(
531
+ `Authentication unavailable: ${error instanceof Error ? error.message : String(error)}`,
532
+ ),
533
+ );
534
+ }
535
+
536
+ // Determine Qwen filetype for STS token
537
+ const qwenFileType = detectFileType(file.name).qwenFileType;
538
+
539
+ const stsData = await getSTSToken(
540
+ file.name,
541
+ file.size,
542
+ qwenFileType,
543
+ headers,
544
+ );
545
+ const fileBuffer = await file.arrayBuffer();
546
+ const fileUrl = await uploadToOSS(fileBuffer, stsData, file.name);
547
+
548
+ return c.json({
549
+ url: fileUrl,
550
+ file_id: stsData.file_id,
551
+ filename: file.name,
552
+ type: qwenFileType,
553
+ });
554
+ } catch (error) {
555
+ console.error(
556
+ "[Upload] Error:",
557
+ error instanceof Error ? error.message : String(error),
558
+ );
559
+ return sendOpenAIError(c, error);
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Qwen file format for images
565
+ */
566
+ export interface QwenFileEntry {
567
+ type: string;
568
+ file: {
569
+ created_at: number;
570
+ data: Record<string, unknown>;
571
+ filename: string;
572
+ hash: string | null;
573
+ id: string;
574
+ user_id: string;
575
+ meta: { name: string; size: number; content_type: string };
576
+ update_at: number;
577
+ lastModified: number;
578
+ name: string;
579
+ webkitRelativePath: string;
580
+ size: number;
581
+ type: string;
582
+ };
583
+ id: string;
584
+ url: string;
585
+ name: string;
586
+ collection_name: string;
587
+ progress: number;
588
+ status: string;
589
+ greenNet: string;
590
+ size: number;
591
+ error: string;
592
+ itemId: string;
593
+ file_type: string;
594
+ showType: string;
595
+ file_class: string;
596
+ uploadTaskId: string;
597
+ }
598
+
599
+ /**
600
+ * Process OpenAI-style image/video content into Qwen file format
601
+ */
602
+ export async function processImagesForQwen(
603
+ content: Array<{
604
+ type: string;
605
+ text?: string;
606
+ image_url?: { url: string };
607
+ video_url?: { url: string };
608
+ audio_url?: { url: string };
609
+ file_url?: { url: string };
610
+ }>,
611
+ headers: Record<string, string>,
612
+ ): Promise<{ text: string; files: QwenFileEntry[] }> {
613
+ const textParts: string[] = [];
614
+ const files: QwenFileEntry[] = [];
615
+
616
+ for (const part of content) {
617
+ if (part.type === "text" && part.text) {
618
+ textParts.push(part.text);
619
+ } else if (
620
+ (part.type === "image_url" && part.image_url?.url) ||
621
+ (part.type === "video_url" && part.video_url?.url) ||
622
+ (part.type === "audio_url" && part.audio_url?.url) ||
623
+ (part.type === "file_url" && part.file_url?.url)
624
+ ) {
625
+ const mediaUrl =
626
+ part.type === "video_url"
627
+ ? part.video_url!.url
628
+ : part.type === "audio_url"
629
+ ? part.audio_url!.url
630
+ : part.type === "file_url"
631
+ ? part.file_url!.url
632
+ : part.image_url!.url;
633
+ let fileUrl = "";
634
+ let filename = "";
635
+ let fileSize = 0;
636
+ let fileId = "";
637
+
638
+ if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
639
+ try {
640
+ const remoteMedia = await downloadRemoteMedia(mediaUrl);
641
+ filename = remoteMedia.filename;
642
+ fileSize = remoteMedia.buffer.length;
643
+ const typeInfo = detectFileType(filename);
644
+ const stsData = await getSTSToken(
645
+ filename,
646
+ fileSize,
647
+ typeInfo.qwenFileType,
648
+ headers,
649
+ );
650
+ fileUrl = await uploadToOSS(remoteMedia.buffer, stsData, filename);
651
+ fileId = stsData.file_id;
652
+ } catch (err: any) {
653
+ console.warn(
654
+ `[Upload] Failed to re-upload remote media, falling back to source URL: ${err.message}`,
655
+ );
656
+ fileUrl = mediaUrl;
657
+ filename = getFilenameFromUrl(mediaUrl);
658
+ fileId = uuidv4();
659
+ }
660
+ } else if (mediaUrl.startsWith("data:")) {
661
+ try {
662
+ // Detect type from data URI
663
+ const dataMime = mediaUrl.match(/^data:([^;]+)/)?.[1] || "";
664
+ const isVideoData = dataMime.startsWith("video/");
665
+ const isAudioData = dataMime.startsWith("audio/");
666
+ const detectedExt =
667
+ getExtensionFromMime(dataMime) ||
668
+ (isVideoData ? "mp4" : isAudioData ? "mp3" : "png");
669
+ const base64Data = mediaUrl.split(",")[1];
670
+ const buffer = Buffer.from(base64Data, "base64");
671
+ filename = `${isVideoData ? "video" : isAudioData ? "audio" : "file"}_${Date.now()}.${detectedExt}`;
672
+ fileSize = buffer.length;
673
+ const typeInfo = detectFileType(filename);
674
+ const stsData = await getSTSToken(
675
+ filename,
676
+ fileSize,
677
+ typeInfo.qwenFileType,
678
+ headers,
679
+ );
680
+ fileUrl = await uploadToOSS(buffer, stsData, filename);
681
+ fileId = stsData.file_id;
682
+ } catch (err: any) {
683
+ console.error("❌ [Upload] Failed to upload media:", err.message);
684
+ continue;
685
+ }
686
+ }
687
+
688
+ if (fileUrl) {
689
+ const typeInfo = detectFileType(filename);
690
+ files.push({
691
+ type: typeInfo.showType,
692
+ file: {
693
+ created_at: Date.now(),
694
+ data: {},
695
+ filename,
696
+ hash: null,
697
+ id: fileId,
698
+ user_id: "proxy-user",
699
+ meta: {
700
+ name: filename,
701
+ size: fileSize,
702
+ content_type: typeInfo.mime,
703
+ },
704
+ update_at: Date.now(),
705
+ lastModified: Date.now(),
706
+ name: filename,
707
+ webkitRelativePath: "",
708
+ size: fileSize,
709
+ type: typeInfo.mime,
710
+ },
711
+ id: fileId,
712
+ url: fileUrl,
713
+ name: filename,
714
+ collection_name: "",
715
+ progress: 100,
716
+ status: "uploaded",
717
+ greenNet: "success",
718
+ size: fileSize,
719
+ error: "",
720
+ itemId: uuidv4(),
721
+ file_type: typeInfo.mime,
722
+ showType: typeInfo.showType,
723
+ file_class: typeInfo.fileClass,
724
+ uploadTaskId: uuidv4(),
725
+ });
726
+ }
727
+ }
728
+ }
729
+
730
+ return { text: textParts.join("\n"), files };
731
+ }