qwenproxy-cli 1.0.0 → 1.0.2

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 -14
  2. package/README.md +906 -906
  3. package/bin/qwenproxy.js +5 -1
  4. package/package.json +77 -78
  5. package/src/api/error-classifier.ts +159 -159
  6. package/src/api/error-helpers.ts +118 -118
  7. package/src/api/models.ts +261 -261
  8. package/src/api/server.ts +860 -859
  9. package/src/cache/memory-cache.ts +385 -385
  10. package/src/clean-cache.ts +204 -204
  11. package/src/core/account-concurrency.ts +671 -671
  12. package/src/core/account-manager.ts +301 -297
  13. package/src/core/account-priority.ts +163 -163
  14. package/src/core/accounts.ts +186 -186
  15. package/src/core/config.ts +383 -383
  16. package/src/core/crypto-utils.ts +79 -79
  17. package/src/core/database.ts +276 -276
  18. package/src/core/errors.ts +118 -118
  19. package/src/core/logger.ts +269 -269
  20. package/src/core/memory-usage.ts +84 -84
  21. package/src/core/metrics.ts +291 -291
  22. package/src/core/model-alias.ts +77 -77
  23. package/src/core/model-registry.ts +544 -544
  24. package/src/core/mutex.ts +119 -119
  25. package/src/core/paths.ts +199 -199
  26. package/src/core/prompt-limits.ts +214 -214
  27. package/src/core/reasoning-effort.ts +102 -102
  28. package/src/core/stream-registry.ts +96 -96
  29. package/src/core/waf-isolation.ts +117 -117
  30. package/src/core/watchdog.ts +195 -195
  31. package/src/delete-chats.ts +23 -23
  32. package/src/index.ts +65 -64
  33. package/src/login.ts +147 -147
  34. package/src/reset-cooldowns.ts +11 -11
  35. package/src/routes/anthropic/index.ts +355 -355
  36. package/src/routes/anthropic/translate.ts +522 -522
  37. package/src/routes/anthropic/types.ts +154 -154
  38. package/src/routes/anthropic/validation.ts +144 -144
  39. package/src/routes/chat/account.ts +1817 -1817
  40. package/src/routes/chat/context.ts +241 -241
  41. package/src/routes/chat/errors.ts +85 -85
  42. package/src/routes/chat/helpers.ts +268 -268
  43. package/src/routes/chat/index.ts +618 -618
  44. package/src/routes/chat/media.ts +285 -285
  45. package/src/routes/chat/retry-policy.ts +754 -754
  46. package/src/routes/chat/stop.ts +98 -98
  47. package/src/routes/chat/streaming.ts +2710 -2710
  48. package/src/routes/chat/validation.ts +526 -526
  49. package/src/routes/chat.ts +2 -2
  50. package/src/routes/completions.ts +290 -290
  51. package/src/routes/images.ts +139 -139
  52. package/src/routes/responses/adapter.ts +503 -503
  53. package/src/routes/responses/index.ts +405 -405
  54. package/src/routes/responses/state.ts +230 -230
  55. package/src/routes/responses/streaming.ts +528 -528
  56. package/src/routes/responses/types.ts +285 -285
  57. package/src/routes/responses/validation.ts +202 -202
  58. package/src/routes/upload.ts +731 -731
  59. package/src/routes/videos.ts +214 -214
  60. package/src/services/auth-playwright.ts +173 -173
  61. package/src/services/captcha-coordinator.ts +161 -161
  62. package/src/services/captcha-solver.ts +553 -553
  63. package/src/services/chat-cleanup.ts +80 -80
  64. package/src/services/context-meter.ts +317 -317
  65. package/src/services/fingerprint.ts +242 -242
  66. package/src/services/human-behavior.ts +173 -173
  67. package/src/services/media-generation.ts +1748 -1748
  68. package/src/services/playwright.ts +2878 -2800
  69. package/src/services/qwen-chat-pool.ts +345 -345
  70. package/src/services/qwen-errors.ts +133 -133
  71. package/src/services/qwen-headers.ts +79 -79
  72. package/src/services/qwen-thread-state.ts +393 -393
  73. package/src/services/qwen-url.ts +19 -19
  74. package/src/services/qwen.ts +3126 -3126
  75. package/src/services/session-keeper.ts +88 -88
  76. package/src/services/token-estimation-metrics.ts +118 -118
  77. package/src/sync/claude-code.ts +75 -75
  78. package/src/sync/codex.ts +123 -123
  79. package/src/sync/index.ts +362 -362
  80. package/src/sync/omp.ts +105 -105
  81. package/src/sync/opencode.ts +214 -214
  82. package/src/sync/types.ts +53 -53
  83. package/src/sync/utils.ts +27 -27
  84. package/src/sync-clients.ts +189 -189
  85. package/src/tools/instructions.ts +137 -137
  86. package/src/tools/manifest.ts +81 -81
  87. package/src/tools/parser.ts +2989 -2989
  88. package/src/tools/toolcall-tags.ts +142 -142
  89. package/src/tui/app.ts +259 -264
  90. package/src/tui/index.ts +61 -61
  91. package/src/tui/markdown.ts +258 -258
  92. package/src/tui/proxy-client.ts +331 -326
  93. package/src/tui/screen.ts +294 -278
  94. package/src/tui/server-manager.ts +270 -270
  95. package/src/tui/theme.ts +432 -432
  96. package/src/tui/types.ts +33 -33
  97. package/src/tui/views/accounts-view.ts +656 -656
  98. package/src/tui/views/chat-view.ts +1018 -823
  99. package/src/tui/views/logs-view.ts +479 -413
  100. package/src/tui/views/status-view.ts +204 -204
  101. package/src/tui/views/storage-view.ts +304 -291
  102. package/src/tui/views/sync-view.ts +409 -409
  103. package/src/types/ali-oss.d.ts +32 -32
  104. package/src/update-cli.ts +121 -0
  105. package/src/utils/context-truncation.ts +84 -84
  106. package/src/utils/json.ts +380 -380
  107. package/src/utils/session-id.ts +37 -37
  108. package/src/utils/tool-call-guard.ts +84 -84
  109. package/src/utils/types.ts +109 -109
@@ -1,731 +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
- }
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
+ }