@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pedrofariasx/qwenproxy",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Local OpenAI-compatible proxy API that routes requests to Qwen (chat.qwen.ai) via Playwright browser automation.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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
- fileUrl = mediaUrl;
612
- filename = mediaUrl.split("/").pop()?.split("?")[0] || "file.bin";
613
- fileId = uuidv4();
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] ||
@@ -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 using warm pool headers (no extra Playwright roundtrip)
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': chatHeaders['bx-ua'] || '',
363
- 'bx-umidtoken': chatHeaders['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
- // Process all multimodal parts in parallel
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
+ });