@pedrofariasx/qwenproxy 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/routes/upload.ts +40 -3
- package/src/services/qwen.ts +16 -7
- package/src/tests/media/audio.mp3 +0 -0
- package/src/tests/media/doc1.pdf +105 -0
- package/src/tests/media/doc2.xlsx +0 -0
- package/src/tests/media/farias.png +0 -0
- package/src/tests/media/video.mp4 +0 -0
- package/src/tests/multimodal.test.ts +146 -0
package/package.json
CHANGED
package/src/routes/upload.ts
CHANGED
|
@@ -137,6 +137,10 @@ async function uploadToOSS(
|
|
|
137
137
|
endpoint,
|
|
138
138
|
} = stsData;
|
|
139
139
|
|
|
140
|
+
if (process.env.TEST_MOCK_PLAYWRIGHT) {
|
|
141
|
+
return stsData.file_url.split("?")[0];
|
|
142
|
+
}
|
|
143
|
+
|
|
140
144
|
const OSS = (await import("ali-oss")).default;
|
|
141
145
|
const client = new OSS({
|
|
142
146
|
region,
|
|
@@ -608,9 +612,40 @@ export async function processImagesForQwen(
|
|
|
608
612
|
let fileId = "";
|
|
609
613
|
|
|
610
614
|
if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
615
|
+
try {
|
|
616
|
+
const downloadRes = await fetch(mediaUrl);
|
|
617
|
+
if (!downloadRes.ok) {
|
|
618
|
+
console.error(`[Upload] Failed to download media: ${downloadRes.status} ${mediaUrl}`);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const buffer = Buffer.from(await downloadRes.arrayBuffer());
|
|
622
|
+
fileSize = buffer.length;
|
|
623
|
+
filename = mediaUrl.split("/").pop()?.split("?")[0] || "file.bin";
|
|
624
|
+
if (!filename.includes(".")) {
|
|
625
|
+
const mime = downloadRes.headers.get("content-type") || "";
|
|
626
|
+
const mimeExt: Record<string, string> = {
|
|
627
|
+
"image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
|
|
628
|
+
"image/webp": "webp", "video/mp4": "mp4", "video/webm": "webm",
|
|
629
|
+
"audio/mpeg": "mp3", "audio/wav": "wav", "audio/ogg": "ogg",
|
|
630
|
+
"audio/flac": "flac", "audio/mp4": "m4a", "audio/aac": "aac",
|
|
631
|
+
"application/pdf": "pdf",
|
|
632
|
+
};
|
|
633
|
+
const ext = mimeExt[mime] || "bin";
|
|
634
|
+
filename = `${filename}.${ext}`;
|
|
635
|
+
}
|
|
636
|
+
const typeInfo = detectFileType(filename);
|
|
637
|
+
const stsData = await getSTSToken(
|
|
638
|
+
filename,
|
|
639
|
+
fileSize,
|
|
640
|
+
typeInfo.qwenFileType,
|
|
641
|
+
headers,
|
|
642
|
+
);
|
|
643
|
+
fileUrl = await uploadToOSS(buffer.buffer, stsData, filename);
|
|
644
|
+
fileId = stsData.file_id;
|
|
645
|
+
} catch (err: any) {
|
|
646
|
+
console.error("[Upload] Failed to download/re-upload HTTP media:", err.message);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
614
649
|
} else if (mediaUrl.startsWith("data:")) {
|
|
615
650
|
try {
|
|
616
651
|
// Detect type from data URI
|
|
@@ -631,6 +666,8 @@ export async function processImagesForQwen(
|
|
|
631
666
|
"image/jpeg": "jpg",
|
|
632
667
|
"image/gif": "gif",
|
|
633
668
|
"image/webp": "webp",
|
|
669
|
+
"application/pdf": "pdf",
|
|
670
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
634
671
|
};
|
|
635
672
|
const detectedExt =
|
|
636
673
|
extFromMime[dataMime] ||
|
package/src/services/qwen.ts
CHANGED
|
@@ -351,19 +351,28 @@ export async function createQwenStream(
|
|
|
351
351
|
const chatHeaders = chatEntry.headers;
|
|
352
352
|
const actualParentId: string | null = null;
|
|
353
353
|
|
|
354
|
-
// Process pending multimodal uploads
|
|
354
|
+
// Process pending multimodal uploads — requires full headers with bx-ua/bx-umidtoken
|
|
355
355
|
let resolvedFiles = files || [];
|
|
356
356
|
if (pendingMultimodal && pendingMultimodal.length > 0 && resolvedFiles.length === 0) {
|
|
357
357
|
try {
|
|
358
358
|
const { processImagesForQwen } = await import('../routes/upload.ts');
|
|
359
|
+
const { headers: fullHeaders } = await getQwenHeaders(false, accountId);
|
|
359
360
|
const uploadHeaders: Record<string, string> = {
|
|
360
|
-
cookie: chatHeaders['cookie'] || '',
|
|
361
|
-
'user-agent': chatHeaders['user-agent'] || '',
|
|
362
|
-
'bx-ua':
|
|
363
|
-
'bx-umidtoken':
|
|
364
|
-
'bx-v': chatHeaders['bx-v'] || '',
|
|
361
|
+
cookie: fullHeaders['cookie'] || chatHeaders['cookie'] || '',
|
|
362
|
+
'user-agent': fullHeaders['user-agent'] || chatHeaders['user-agent'] || '',
|
|
363
|
+
'bx-ua': fullHeaders['bx-ua'] || '',
|
|
364
|
+
'bx-umidtoken': fullHeaders['bx-umidtoken'] || '',
|
|
365
|
+
'bx-v': fullHeaders['bx-v'] || chatHeaders['bx-v'] || '',
|
|
365
366
|
};
|
|
366
|
-
|
|
367
|
+
if (!uploadHeaders['bx-ua']) {
|
|
368
|
+
console.warn('[Qwen] Missing bx-ua header for multimodal upload, attempting forced refresh...');
|
|
369
|
+
const { headers: refreshedHeaders } = await getQwenHeaders(true, accountId);
|
|
370
|
+
uploadHeaders['cookie'] = refreshedHeaders['cookie'] || uploadHeaders['cookie'];
|
|
371
|
+
uploadHeaders['user-agent'] = refreshedHeaders['user-agent'] || uploadHeaders['user-agent'];
|
|
372
|
+
uploadHeaders['bx-ua'] = refreshedHeaders['bx-ua'] || '';
|
|
373
|
+
uploadHeaders['bx-umidtoken'] = refreshedHeaders['bx-umidtoken'] || '';
|
|
374
|
+
uploadHeaders['bx-v'] = refreshedHeaders['bx-v'] || uploadHeaders['bx-v'];
|
|
375
|
+
}
|
|
367
376
|
const results = await Promise.all(
|
|
368
377
|
pendingMultimodal.map(parts => processImagesForQwen(parts, uploadHeaders))
|
|
369
378
|
);
|
|
Binary file
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
%PDF-1.4
|
|
2
|
+
%���� ReportLab Generated PDF document http://www.reportlab.com
|
|
3
|
+
1 0 obj
|
|
4
|
+
<<
|
|
5
|
+
/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 6 0 R
|
|
6
|
+
>>
|
|
7
|
+
endobj
|
|
8
|
+
2 0 obj
|
|
9
|
+
<<
|
|
10
|
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
|
11
|
+
>>
|
|
12
|
+
endobj
|
|
13
|
+
3 0 obj
|
|
14
|
+
<<
|
|
15
|
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
|
16
|
+
>>
|
|
17
|
+
endobj
|
|
18
|
+
4 0 obj
|
|
19
|
+
<<
|
|
20
|
+
/Contents 11 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
|
|
21
|
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
22
|
+
>> /Rotate 0 /Trans <<
|
|
23
|
+
|
|
24
|
+
>>
|
|
25
|
+
/Type /Page
|
|
26
|
+
>>
|
|
27
|
+
endobj
|
|
28
|
+
5 0 obj
|
|
29
|
+
<<
|
|
30
|
+
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
|
|
31
|
+
>>
|
|
32
|
+
endobj
|
|
33
|
+
6 0 obj
|
|
34
|
+
<<
|
|
35
|
+
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
|
36
|
+
>>
|
|
37
|
+
endobj
|
|
38
|
+
7 0 obj
|
|
39
|
+
<<
|
|
40
|
+
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 10 0 R /Resources <<
|
|
41
|
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
42
|
+
>> /Rotate 0 /Trans <<
|
|
43
|
+
|
|
44
|
+
>>
|
|
45
|
+
/Type /Page
|
|
46
|
+
>>
|
|
47
|
+
endobj
|
|
48
|
+
8 0 obj
|
|
49
|
+
<<
|
|
50
|
+
/PageMode /UseNone /Pages 10 0 R /Type /Catalog
|
|
51
|
+
>>
|
|
52
|
+
endobj
|
|
53
|
+
9 0 obj
|
|
54
|
+
<<
|
|
55
|
+
/Author (\(anonymous\)) /CreationDate (D:20260604002337+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260604002337+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
|
|
56
|
+
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
|
57
|
+
>>
|
|
58
|
+
endobj
|
|
59
|
+
10 0 obj
|
|
60
|
+
<<
|
|
61
|
+
/Count 2 /Kids [ 4 0 R 7 0 R ] /Type /Pages
|
|
62
|
+
>>
|
|
63
|
+
endobj
|
|
64
|
+
11 0 obj
|
|
65
|
+
<<
|
|
66
|
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 756
|
|
67
|
+
>>
|
|
68
|
+
stream
|
|
69
|
+
Gat=h?Z4XP'ZJu*'^(TQr%k1&p5Q2TKXRk%bMuo&EZsi`411VWp??qa64f)RWBuJ1I!pD?4823P^crF&8HF3qdD$D-6c;WR;/_-_#JS0Za-P@Y7MA07d@3h4-G4sI#%>?N>.donEkJYbW?K3UQ5U6KTi';Q!u;^r:=6d_PFc"M]Km>uX5'bmUbYG6cJuni<C,>g6MuB/1V*fqg!Dn^%S[BchDI0<29#;/K_kpOm9`g2VnB:(CLl:gm^h:_.A'Uu?=Je@8Z=R?BpEedrC?Qdet.**:0J6V5"A9+ant;?kbQH_;\&Dg>HF[=MDSK#.0]a6E#\1#ngZr:"c&qZn;L90'oG.[IT#T[R!H3A./7!GaFQ;=0%;L(b<-&.Za`^bjXK_b-(&7LLOEtIido^-k?*\`=)YD&9L%#rmCOaO:Q!_9%j"uih[eb2V"ZfYi_ng>,X%]@\J\$(eKQAo(18'/_jZH;_pW&6Zl,\HKGr3W7QXEUi['B@Ga$OBc=bc9X'GHp#;SkEV/'YgDiO1hVTFtA4KlYE<EU7+4)j&>/F$<&>\Q#FG3[SG@i!Xs*EgJ+Rj(CC7jOL'[FMU_?!^gLQ.fgA_;T<^V[^?YSP\S,\\goB1NFTX"m+@'p9F!YT`%AKd>>A(Vkm7:HYd('.`5>cjFSEC6>F'*k/4O/S8Joc9e'R8Q;Zh!S+3F<5*=Goam]8I40"\2EjN2hQ20i7\pDeo(lp172]g1X4oNhIf#I^6ND,CYU\+\'c)QK~>endstream
|
|
70
|
+
endobj
|
|
71
|
+
12 0 obj
|
|
72
|
+
<<
|
|
73
|
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 619
|
|
74
|
+
>>
|
|
75
|
+
stream
|
|
76
|
+
Gat=(9lHLd&A@sBN&\?_+^RhJpdV?^:$X-$bcU,<M^K"b_V2Up.>m]R4(*XY9>Mf=6=P]D_<a9$9^#s14im[V@Q;&3^g)[;J9d.[nH(nnUYUa6Dk72QmP-'p_LN3IQo#8[)%i0WF9KgDaorR1<3BF=N?a]NV?R]4."lAM:k+#j2oHeRNE`u#l`fD1X;)r*PK%Yo"K'nM#oB7R=.hJ@+CkeXT#"[-QlB^l:G8WLEVJmhhf]JHiF\!nFEK@*fp8-"QU$@m=2S`i9?`_priLoBUEks*=hg/S5rB?P6QTS_=_`Pk;!V*CmrM9<od<IQ3;O-X<ot</AJF]O35PU\SA(ii_m!@6_j2ASfNYNk%N=:@caIrR?WT2<YiM(2I,4,)o,Y;AB^.tKpj4S'RYV*l?L%p!9Wh5p1U)$Zmcd;0O34c-:<1@5W2]FlSh)/k=u35+Fk95rDJ2"/pjth'lPY0*0.+`*DC/+DUC)\;?M@C%F+;hOmZA$,/,=1U_/EG@;QY:5Ihj#jGU)id[-T]PF[Br$V%bWH6>.=(Jled@2iksrQS?ZC<,Mb?mklR6(5qYNR2[\;0t!/.-5dOfB(Q1e\c#kX9j;nrE*Z*YmO=+o!WC&Bao~>endstream
|
|
77
|
+
endobj
|
|
78
|
+
xref
|
|
79
|
+
0 13
|
|
80
|
+
0000000000 65535 f
|
|
81
|
+
0000000073 00000 n
|
|
82
|
+
0000000134 00000 n
|
|
83
|
+
0000000241 00000 n
|
|
84
|
+
0000000353 00000 n
|
|
85
|
+
0000000558 00000 n
|
|
86
|
+
0000000635 00000 n
|
|
87
|
+
0000000740 00000 n
|
|
88
|
+
0000000945 00000 n
|
|
89
|
+
0000001014 00000 n
|
|
90
|
+
0000001297 00000 n
|
|
91
|
+
0000001363 00000 n
|
|
92
|
+
0000002210 00000 n
|
|
93
|
+
trailer
|
|
94
|
+
<<
|
|
95
|
+
/ID
|
|
96
|
+
[<eb349826a1153a222f9fd87f711c0d31><eb349826a1153a222f9fd87f711c0d31>]
|
|
97
|
+
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
|
|
98
|
+
|
|
99
|
+
/Info 9 0 R
|
|
100
|
+
/Root 8 0 R
|
|
101
|
+
/Size 13
|
|
102
|
+
>>
|
|
103
|
+
startxref
|
|
104
|
+
2920
|
|
105
|
+
%%EOF
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { serve } from '@hono/node-server';
|
|
8
|
+
import { app } from '../api/server.js';
|
|
9
|
+
import { initPlaywright, closePlaywright } from '../services/playwright.ts';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
const mediaDir = path.join(__dirname, 'media');
|
|
14
|
+
|
|
15
|
+
function isPortAvailable(port: number): Promise<boolean> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const server = net.createServer();
|
|
18
|
+
server.once('error', () => resolve(false));
|
|
19
|
+
server.once('listening', () => {
|
|
20
|
+
server.close(() => resolve(true));
|
|
21
|
+
});
|
|
22
|
+
server.listen(port);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function getFreePort(startPort: number): Promise<number> {
|
|
27
|
+
let port = startPort;
|
|
28
|
+
while (true) {
|
|
29
|
+
const available = await isPortAvailable(port);
|
|
30
|
+
if (available) return port;
|
|
31
|
+
port++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fileToDataUri(filePath: string): string {
|
|
36
|
+
const buffer = fs.readFileSync(filePath);
|
|
37
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
38
|
+
const mimeMap: Record<string, string> = {
|
|
39
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
40
|
+
mp4: 'video/mp4', mp3: 'audio/mpeg',
|
|
41
|
+
pdf: 'application/pdf',
|
|
42
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
43
|
+
};
|
|
44
|
+
return `data:${mimeMap[ext] || 'application/octet-stream'};base64,${buffer.toString('base64')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function sendMultimodalRequest(
|
|
48
|
+
port: number,
|
|
49
|
+
prompt: string,
|
|
50
|
+
urlType: string,
|
|
51
|
+
dataUri: string,
|
|
52
|
+
): Promise<{ content: string; reasoning: string }> {
|
|
53
|
+
const contentPart: any = { type: urlType };
|
|
54
|
+
if (urlType === 'image_url') contentPart.image_url = { url: dataUri };
|
|
55
|
+
else if (urlType === 'video_url') contentPart.video_url = { url: dataUri };
|
|
56
|
+
else if (urlType === 'audio_url') contentPart.audio_url = { url: dataUri };
|
|
57
|
+
else contentPart.file_url = { url: dataUri };
|
|
58
|
+
|
|
59
|
+
const response = await fetch(`http://localhost:${port}/v1/chat/completions`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
model: 'qwen3.6-plus',
|
|
64
|
+
messages: [{ role: 'user', content: [
|
|
65
|
+
{ type: 'text', text: prompt },
|
|
66
|
+
contentPart,
|
|
67
|
+
]}],
|
|
68
|
+
stream: true
|
|
69
|
+
})
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
assert.strictEqual(response.status, 200, `Expected 200, got ${response.status}`);
|
|
73
|
+
|
|
74
|
+
const reader = response.body!.getReader();
|
|
75
|
+
const decoder = new TextDecoder();
|
|
76
|
+
let content = '';
|
|
77
|
+
let reasoning = '';
|
|
78
|
+
let buffer = '';
|
|
79
|
+
|
|
80
|
+
while (true) {
|
|
81
|
+
const { done, value } = await reader.read();
|
|
82
|
+
if (done) break;
|
|
83
|
+
buffer += decoder.decode(value, { stream: true });
|
|
84
|
+
const lines = buffer.split('\n');
|
|
85
|
+
buffer = lines.pop() || '';
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
89
|
+
const dataStr = trimmed.slice(6);
|
|
90
|
+
if (dataStr === '[DONE]') continue;
|
|
91
|
+
try {
|
|
92
|
+
const chunk = JSON.parse(dataStr);
|
|
93
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
94
|
+
if (delta?.content) content += delta.content;
|
|
95
|
+
if (delta?.reasoning_content) reasoning += delta.reasoning_content;
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { content, reasoning };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('Multimodal: all media files with real Qwen responses', { skip: process.env.CI ? 'Requires real accounts - skipped in CI' : false }, async () => {
|
|
104
|
+
const port = await getFreePort(3200);
|
|
105
|
+
const server = serve({ fetch: app.fetch, port });
|
|
106
|
+
console.log(`[MultimodalTest] Server started on port ${port}`);
|
|
107
|
+
|
|
108
|
+
await initPlaywright(true);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const scenarios = [
|
|
112
|
+
{ file: 'farias.png', urlType: 'image_url', prompt: 'Descreva essa imagem em detalhes', requireContent: true },
|
|
113
|
+
{ file: 'video.mp4', urlType: 'video_url', prompt: 'Descreva o conteúdo deste vídeo', requireContent: true },
|
|
114
|
+
{ file: 'audio.mp3', urlType: 'audio_url', prompt: 'Transcreva e descreva o que é dito neste áudio', requireContent: true },
|
|
115
|
+
{ file: 'doc1.pdf', urlType: 'file_url', prompt: 'Resuma o conteúdo deste documento PDF', requireContent: false },
|
|
116
|
+
{ file: 'doc2.xlsx', urlType: 'file_url', prompt: 'Analise os dados desta planilha e descreva o que contém', requireContent: false },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
for (const scenario of scenarios) {
|
|
120
|
+
const filePath = path.join(mediaDir, scenario.file);
|
|
121
|
+
if (!fs.existsSync(filePath)) {
|
|
122
|
+
console.log(`[MultimodalTest] SKIP ${scenario.file} - not found`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const dataUri = fileToDataUri(filePath);
|
|
127
|
+
console.log(`[MultimodalTest] Sending ${scenario.file} (${(fs.statSync(filePath).size / 1024).toFixed(1)}KB)...`);
|
|
128
|
+
|
|
129
|
+
const { content, reasoning } = await sendMultimodalRequest(port, scenario.prompt, scenario.urlType, dataUri);
|
|
130
|
+
|
|
131
|
+
console.log(`[MultimodalTest] ${scenario.file} => ${content.length} chars`);
|
|
132
|
+
if (content) console.log(` Content: ${content.substring(0, 300)}`);
|
|
133
|
+
if (reasoning) console.log(` Reasoning: ${reasoning.substring(0, 150)}...`);
|
|
134
|
+
|
|
135
|
+
if (scenario.requireContent) {
|
|
136
|
+
assert.ok(content.length > 10, `${scenario.file}: expected meaningful response, got ${content.length} chars`);
|
|
137
|
+
} else if (content.length === 0) {
|
|
138
|
+
console.log(`[MultimodalTest] WARN: ${scenario.file} returned empty response (Qwen may not support this file type via ${scenario.urlType})`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
await closePlaywright();
|
|
143
|
+
server.close();
|
|
144
|
+
console.log('[MultimodalTest] Done.');
|
|
145
|
+
}
|
|
146
|
+
});
|