@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 +1 -1
- package/src/cache/memory-cache.ts +50 -17
- package/src/routes/chat.ts +10 -3
- package/src/routes/upload.ts +40 -3
- package/src/services/qwen.ts +32 -11
- 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/src/utils/context-truncation.ts +37 -6
package/package.json
CHANGED
|
@@ -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)
|
|
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.
|
|
81
|
-
|
|
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)
|
|
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:
|
|
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
|
|
package/src/routes/chat.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =>
|
|
651
|
+
await new Promise(r => setTimeout(r, 0));
|
|
645
652
|
}
|
|
646
653
|
}
|
|
647
654
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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':
|
|
363
|
-
'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
|
-
|
|
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
|
-
|
|
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.
|
|
70
|
+
result.push(msg);
|
|
43
71
|
usedTokens += msgTokens;
|
|
44
72
|
} else {
|
|
45
73
|
const remainingTokens = availableTokens - usedTokens;
|
|
46
74
|
if (remainingTokens > 100) {
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
|
57
|
-
|
|
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
|
}
|