@pedrofariasx/qwenproxy 1.1.0 → 1.2.1

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.1",
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": {
@@ -19,16 +19,34 @@ export class MemoryCache {
19
19
  private defaultTTL: number
20
20
  private prefix: string
21
21
  private cleanupInterval: NodeJS.Timeout | null
22
+ private maxEntries: number
23
+ private totalBytes: number
22
24
 
23
- constructor(options?: { prefix?: string; defaultTTL?: number }) {
25
+ constructor(options?: { prefix?: string; defaultTTL?: number; maxEntries?: number }) {
24
26
  this.prefix = options?.prefix || 'qwenproxy:'
25
27
  this.defaultTTL = options?.defaultTTL || config.cache.defaultTTL
28
+ this.maxEntries = options?.maxEntries || 10000
26
29
  this.store = new Map()
30
+ this.totalBytes = 0
27
31
  this.cleanupInterval = null
28
32
 
29
33
  this.startCleanup()
30
34
  }
31
35
 
36
+ private entryByteSize(key: string, value: any): number {
37
+ return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(value))
38
+ }
39
+
40
+ private evictLRU(): void {
41
+ const oldest = this.store.keys().next()
42
+ if (!oldest.done) {
43
+ const evicted = this.store.get(oldest.value)
44
+ if (evicted) this.totalBytes -= this.entryByteSize(oldest.value, evicted.value)
45
+ this.store.delete(oldest.value)
46
+ metrics.increment('cache.evicted')
47
+ }
48
+ }
49
+
32
50
  private startCleanup(): void {
33
51
  this.cleanupInterval = setInterval(() => {
34
52
  const now = Date.now()
@@ -48,11 +66,22 @@ export class MemoryCache {
48
66
  const serialized = JSON.stringify(value)
49
67
  const effectiveTTL = ttl || this.defaultTTL
50
68
  const fullKey = this.prefix + key
69
+ const entrySize = this.entryByteSize(fullKey, value)
70
+
71
+ if (this.store.has(fullKey)) {
72
+ const oldEntry = this.store.get(fullKey)
73
+ if (oldEntry) this.totalBytes -= this.entryByteSize(fullKey, oldEntry.value)
74
+ } else {
75
+ while (this.store.size >= this.maxEntries) {
76
+ this.evictLRU()
77
+ }
78
+ }
51
79
 
52
80
  this.store.set(fullKey, {
53
81
  value,
54
82
  expiresAt: Date.now() + (effectiveTTL * 1000)
55
83
  })
84
+ this.totalBytes += entrySize
56
85
 
57
86
  metrics.increment('cache.set')
58
87
  metrics.histogram('cache.value.size', Buffer.byteLength(serialized))
@@ -66,26 +95,39 @@ export class MemoryCache {
66
95
  metrics.histogram('cache.get.latency', Date.now() - start)
67
96
 
68
97
  if (!entry || entry.expiresAt <= Date.now()) {
69
- if (entry) this.store.delete(fullKey)
98
+ if (entry) {
99
+ this.totalBytes -= this.entryByteSize(fullKey, entry.value)
100
+ this.store.delete(fullKey)
101
+ }
70
102
  metrics.increment('cache.miss')
71
103
  return null
72
104
  }
73
105
 
106
+ this.store.delete(fullKey)
107
+ this.store.set(fullKey, entry)
108
+
74
109
  metrics.increment('cache.hit')
75
110
  return entry.value as T
76
111
  }
77
112
 
78
113
  async delete(key: CacheKey): Promise<void> {
79
114
  const fullKey = this.prefix + key
80
- this.store.delete(fullKey)
81
- metrics.increment('cache.deleted')
115
+ const entry = this.store.get(fullKey)
116
+ if (entry) {
117
+ this.totalBytes -= this.entryByteSize(fullKey, entry.value)
118
+ this.store.delete(fullKey)
119
+ metrics.increment('cache.deleted')
120
+ }
82
121
  }
83
122
 
84
123
  async exists(key: CacheKey): Promise<boolean> {
85
124
  const fullKey = this.prefix + key
86
125
  const entry = this.store.get(fullKey)
87
126
  if (!entry || entry.expiresAt <= Date.now()) {
88
- if (entry) this.store.delete(fullKey)
127
+ if (entry) {
128
+ this.totalBytes -= this.entryByteSize(fullKey, entry.value)
129
+ this.store.delete(fullKey)
130
+ }
89
131
  return false
90
132
  }
91
133
  return true
@@ -157,20 +199,10 @@ export class MemoryCache {
157
199
  keysCount?: number
158
200
  memoryUsage?: string
159
201
  }> {
160
- const now = Date.now()
161
- let validKeys = 0
162
- let totalBytes = 0
163
- for (const [key, entry] of this.store.entries()) {
164
- if (entry.expiresAt > now) {
165
- validKeys++
166
- totalBytes += Buffer.byteLength(JSON.stringify(entry.value)) + Buffer.byteLength(key)
167
- }
168
- }
169
-
170
202
  return {
171
203
  connected: true,
172
- keysCount: validKeys,
173
- memoryUsage: `${(totalBytes / 1024).toFixed(2)}KB`
204
+ keysCount: this.store.size,
205
+ memoryUsage: `${(this.totalBytes / 1024).toFixed(2)}KB`
174
206
  }
175
207
  }
176
208
 
@@ -180,6 +212,7 @@ export class MemoryCache {
180
212
  this.cleanupInterval = null
181
213
  }
182
214
  this.store.clear()
215
+ this.totalBytes = 0
183
216
  }
184
217
  }
185
218
 
@@ -209,7 +209,7 @@ export async function chatCompletions(c: Context) {
209
209
  });
210
210
  const toolsJson = JSON.stringify(formattedTools, null, 2);
211
211
 
212
- systemPrompt += `\n\n# TOOLS AVAILABLE\nYou have access to the following tools:\n${toolsJson}\n\n# TOOL CALLING FORMAT (MANDATORY)\nTo use a tool, you MUST output a JSON object wrapped EXACTLY in these tags:\n<tool_call>\n{"name": "tool_name", "arguments": {"param_name": "value"}}\n</tool_call>\n\nEXAMPLE OF MULTIPLE TOOL CALLS:\n<tool_call>\n{"name": "read_file", "arguments": {"path": "file1.txt"}}\n</tool_call>\n<tool_call>\n{"name": "read_file", "arguments": {"path": "file2.txt"}}\n</tool_call>\n\nCRITICAL RULES:\n1. ONLY use the tags above for tool calling. NEVER output raw JSON without tags.\n2. You can call multiple tools by outputting multiple <tool_call> blocks consecutively.\n3. Do NOT output any other text (explanations, chat, etc.) after your <tool_call> blocks. Wait for the user to provide the tool response.\n4. The JSON inside the tags MUST be valid and include ALL required braces and the "arguments" field.\n5. If you need to use a tool, do it IMMEDIATELY without preamble.\n\n`;
212
+ systemPrompt += `\n\n# TOOLS AVAILABLE\nYou have access to the following tools:\n${toolsJson}\n\n# TOOL CALLING FORMAT (MANDATORY)\nTo use a tool, you MUST output a JSON object wrapped EXACTLY in <tool_call> tags:\n\n<tool_call>\n{"name": "tool_name", "arguments": {"param_name": "value"}}\n</tool_call>\n\nEXAMPLE OF MULTIPLE TOOL CALLS:\n<tool_call>\n{"name": "read_file", "arguments": {"path": "file1.txt"}}\n</tool_call>\n<tool_call>\n{"name": "read_file", "arguments": {"path": "file2.txt"}}\n</tool_call>\n\nCRITICAL RULES:\n1. ONLY use the tags above for tool calling. NEVER output raw JSON without tags.\n2. You can call multiple tools by outputting multiple <tool_call> blocks consecutively.\n3. Do NOT output any other text (explanations, chat, etc.) after your <tool_call> blocks. Wait for the user to provide the tool response.\n4. The JSON inside the tags MUST be valid and include ALL required braces and the "arguments" field.\n5. If you need to use a tool, do it IMMEDIATELY without preamble.\n6. NEVER invent, guess, or hallucinate tool names. You MUST ONLY use the exact tool names provided in the 'TOOLS AVAILABLE' list above. Calling an unlisted tool will result in a hard execution error.\n\n`;
213
213
 
214
214
  if (bodyAny.tool_choice && typeof bodyAny.tool_choice === 'object' && bodyAny.tool_choice.function) {
215
215
  const forcedTool = bodyAny.tool_choice.function.name;
@@ -220,15 +220,22 @@ export async function chatCompletions(c: Context) {
220
220
  const modelId = body.model.replace('-no-thinking', '');
221
221
  const modelContextWindow = getModelContextWindow(modelId)
222
222
  const estimatedTokens = estimateTokenCount(systemPrompt + prompt);
223
+ const hasTools = Array.isArray(bodyAny.tools) && bodyAny.tools.length > 0;
223
224
 
224
225
  let finalPrompt: string;
225
226
  if (estimatedTokens > modelContextWindow - 1000) {
226
227
  const truncated = truncateMessages(messages, modelContextWindow, systemPrompt);
227
- finalPrompt = truncated.map(m => `${m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : m.role}: ${m.content}`).join('\n\n');
228
+ const truncatedBody = truncated.map(m => `${m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : m.role}: ${m.content}`).join('\n\n');
229
+ finalPrompt = systemPrompt ? `${systemPrompt}\n\n${truncatedBody}` : truncatedBody;
228
230
  } else {
229
231
  finalPrompt = systemPrompt ? `${systemPrompt}\n${prompt}` : prompt;
230
232
  }
231
233
 
234
+ // Reforço de instrução de tool call para contextos longos (mitiga "Lost in the Middle")
235
+ if (hasTools && estimatedTokens > 15000) {
236
+ finalPrompt += '\n\n[CRITICAL REMINDER: You MUST use the exact <tool_call> JSON format specified in the system instructions. Do not hallucinate tool names or output raw JSON without the tags.]';
237
+ }
238
+
232
239
  const isThinkingModel = !body.model.includes('no-thinking');
233
240
 
234
241
  // A session is new if it doesn't have any assistant messages yet.
@@ -641,7 +648,7 @@ export async function chatCompletions(c: Context) {
641
648
  // Periodic yielding to prevent event loop starvation
642
649
  chunkCount++;
643
650
  if (chunkCount % 100 === 0) {
644
- await new Promise(r => setImmediate(r));
651
+ await new Promise(r => setTimeout(r, 0));
645
652
  }
646
653
  }
647
654
 
@@ -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] ||
@@ -66,6 +66,9 @@ interface WarmPoolEntry {
66
66
  const warmPool: Map<string, WarmPoolEntry[]> = (globalThis as any)._warmPool || new Map();
67
67
  (globalThis as any)._warmPool = warmPool;
68
68
 
69
+ const refillPromises: Map<string, Promise<void>> = (globalThis as any)._refillPromises || new Map();
70
+ (globalThis as any)._refillPromises = refillPromises;
71
+
69
72
  const WARM_POOL_SIZE = 5;
70
73
  const WARM_POOL_TTL_MS = 10 * 60 * 1000;
71
74
 
@@ -128,15 +131,21 @@ async function refillPoolForAccount(accountId: string) {
128
131
  if (!pool) { pool = []; warmPool.set(accountId, pool); }
129
132
  cleanupStalePool(accountId);
130
133
  const need = Math.max(0, WARM_POOL_SIZE - pool.length);
131
- for (let i = 0; i < need; i++) {
134
+
135
+ const creationPromises = Array.from({ length: need }, async () => {
132
136
  try {
133
137
  const headers = await getBasicQwenHeaders(accountId === 'global' ? undefined : accountId);
134
138
  const chatId = await createRealQwenChat(headers);
135
- pool.push({ chatId, headers, accountId, timestamp: Date.now() });
139
+ return { chatId, headers, accountId, timestamp: Date.now() };
136
140
  } catch (err) {
137
141
  console.error(`[WarmPool] refill failed for ${accountId}:`, (err as Error).message);
138
- break;
142
+ return null;
139
143
  }
144
+ });
145
+
146
+ const results = await Promise.all(creationPromises);
147
+ for (const entry of results) {
148
+ if (entry) pool.push(entry);
140
149
  }
141
150
  }
142
151
 
@@ -146,7 +155,10 @@ export async function getWarmedChat(accountId?: string) {
146
155
  if (!pool) { pool = []; warmPool.set(key, pool); }
147
156
  cleanupStalePool(key);
148
157
  if (pool.length === 0) {
149
- await refillPoolForAccount(key);
158
+ if (!refillPromises.has(key)) {
159
+ refillPromises.set(key, refillPoolForAccount(key).finally(() => refillPromises.delete(key)));
160
+ }
161
+ await refillPromises.get(key);
150
162
  }
151
163
  if (pool.length === 0) throw new Error(`Warm pool empty for ${key}`);
152
164
  return pool.shift()!;
@@ -351,19 +363,28 @@ export async function createQwenStream(
351
363
  const chatHeaders = chatEntry.headers;
352
364
  const actualParentId: string | null = null;
353
365
 
354
- // Process pending multimodal uploads using warm pool headers (no extra Playwright roundtrip)
366
+ // Process pending multimodal uploads requires full headers with bx-ua/bx-umidtoken
355
367
  let resolvedFiles = files || [];
356
368
  if (pendingMultimodal && pendingMultimodal.length > 0 && resolvedFiles.length === 0) {
357
369
  try {
358
370
  const { processImagesForQwen } = await import('../routes/upload.ts');
371
+ const { headers: fullHeaders } = await getQwenHeaders(false, accountId);
359
372
  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'] || '',
373
+ cookie: fullHeaders['cookie'] || chatHeaders['cookie'] || '',
374
+ 'user-agent': fullHeaders['user-agent'] || chatHeaders['user-agent'] || '',
375
+ 'bx-ua': fullHeaders['bx-ua'] || '',
376
+ 'bx-umidtoken': fullHeaders['bx-umidtoken'] || '',
377
+ 'bx-v': fullHeaders['bx-v'] || chatHeaders['bx-v'] || '',
365
378
  };
366
- // Process all multimodal parts in parallel
379
+ if (!uploadHeaders['bx-ua']) {
380
+ console.warn('[Qwen] Missing bx-ua header for multimodal upload, attempting forced refresh...');
381
+ const { headers: refreshedHeaders } = await getQwenHeaders(true, accountId);
382
+ uploadHeaders['cookie'] = refreshedHeaders['cookie'] || uploadHeaders['cookie'];
383
+ uploadHeaders['user-agent'] = refreshedHeaders['user-agent'] || uploadHeaders['user-agent'];
384
+ uploadHeaders['bx-ua'] = refreshedHeaders['bx-ua'] || '';
385
+ uploadHeaders['bx-umidtoken'] = refreshedHeaders['bx-umidtoken'] || '';
386
+ uploadHeaders['bx-v'] = refreshedHeaders['bx-v'] || uploadHeaders['bx-v'];
387
+ }
367
388
  const results = await Promise.all(
368
389
  pendingMultimodal.map(parts => processImagesForQwen(parts, uploadHeaders))
369
390
  );
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
+ });
@@ -4,7 +4,35 @@ export interface TruncatedMessage {
4
4
  }
5
5
 
6
6
  export function estimateTokenCount(text: string): number {
7
- return Math.ceil(text.length / 3.5);
7
+ // Divisor conservador (2.5) para evitar estouro silencioso do context window.
8
+ // Tokenizers modernos (como o do Qwen) usam ~1.5 a 2.5 caracteres por token
9
+ // para textos mistos (português, código, caracteres especiais).
10
+ return Math.ceil(text.length / 2.5);
11
+ }
12
+
13
+ function truncateSemantically(content: string, maxChars: number): string {
14
+ if (content.length <= maxChars) return content;
15
+
16
+ const truncated = content.slice(0, maxChars);
17
+
18
+ if (truncated.trimStart().startsWith('{') || truncated.trimStart().startsWith('[')) {
19
+ const lastBrace = Math.max(truncated.lastIndexOf('}'), truncated.lastIndexOf(']'));
20
+ if (lastBrace > maxChars * 0.7) {
21
+ return truncated.slice(0, lastBrace + 1) + ' /* truncated */';
22
+ }
23
+ }
24
+
25
+ const lastNewline = truncated.lastIndexOf('\n');
26
+ if (lastNewline > maxChars * 0.8) {
27
+ return truncated.slice(0, lastNewline) + '\n[Truncated]';
28
+ }
29
+
30
+ const lastSpace = truncated.lastIndexOf(' ');
31
+ if (lastSpace > maxChars * 0.9) {
32
+ return truncated.slice(0, lastSpace) + '... [Truncated]';
33
+ }
34
+
35
+ return truncated + '... [Truncated]';
8
36
  }
9
37
 
10
38
  export function truncateMessages(
@@ -39,13 +67,14 @@ export function truncateMessages(
39
67
  const msgTokens = estimateTokenCount(msg.content);
40
68
 
41
69
  if (usedTokens + msgTokens <= availableTokens) {
42
- result.unshift(msg);
70
+ result.push(msg);
43
71
  usedTokens += msgTokens;
44
72
  } else {
45
73
  const remainingTokens = availableTokens - usedTokens;
46
74
  if (remainingTokens > 100) {
47
- const truncatedContent = msg.content.slice(0, remainingTokens * 3.5);
48
- result.unshift({ role: msg.role, content: `[Truncated] ${truncatedContent}...` });
75
+ const maxChars = Math.floor(remainingTokens * 2.5);
76
+ const truncatedContent = truncateSemantically(msg.content, maxChars);
77
+ result.push({ role: msg.role, content: `[Truncated] ${truncatedContent}` });
49
78
  }
50
79
  break;
51
80
  }
@@ -53,9 +82,11 @@ export function truncateMessages(
53
82
 
54
83
  if (result.length === 0 && normalizedMessages.length > 0) {
55
84
  const lastMsg = normalizedMessages[normalizedMessages.length - 1];
56
- const truncatedContent = lastMsg.content.slice(0, Math.max(200, availableTokens * 3.5));
57
- result.push({ role: lastMsg.role, content: `[Truncated] ${truncatedContent}...` });
85
+ const maxChars = Math.max(200, Math.floor(availableTokens * 2.5));
86
+ const truncatedContent = truncateSemantically(lastMsg.content, maxChars);
87
+ result.push({ role: lastMsg.role, content: `[Truncated] ${truncatedContent}` });
58
88
  }
59
89
 
90
+ result.reverse();
60
91
  return result;
61
92
  }