@pedrofariasx/qwenproxy 1.6.3 → 1.7.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 +2 -3
- package/src/core/config.ts +7 -3
- package/src/routes/chat.ts +287 -13
- package/src/services/playwright.ts +30 -18
- package/src/services/qwen.ts +220 -20
- package/src/tests/contextTruncation.test.ts +21 -0
- package/src/utils/context-truncation.ts +60 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pedrofariasx/qwenproxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@hono/node-server": "^2.0.3",
|
|
23
23
|
"ajv": "^8.20.0",
|
|
24
24
|
"ali-oss": "^6.23.0",
|
|
25
|
-
"better-sqlite3": "^12.10.
|
|
25
|
+
"better-sqlite3": "^12.10.1",
|
|
26
26
|
"dotenv": "^17.4.2",
|
|
27
27
|
"hono": "^4.12.21",
|
|
28
28
|
"playwright": "^1.60.0",
|
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
"@types/ali-oss": "^6.23.3",
|
|
39
39
|
"@types/better-sqlite3": "^7.6.13",
|
|
40
40
|
"@types/node": "^25.9.1",
|
|
41
|
-
"@types/uuid": "^11.0.0",
|
|
42
41
|
"semantic-release": "^25.0.3",
|
|
43
42
|
"typescript": "^6.0.3"
|
|
44
43
|
},
|
package/src/core/config.ts
CHANGED
|
@@ -8,10 +8,12 @@ const envSchema = z.object({
|
|
|
8
8
|
USER_DATA_DIR: z.string().default('./qwen_profiles'),
|
|
9
9
|
USER_AGENT: z.string().default('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'),
|
|
10
10
|
LOG_CONSOLE: z.string().default('false'),
|
|
11
|
-
NAVIGATION_TIMEOUT: z.string().default('
|
|
12
|
-
PAGE_TIMEOUT: z.string().default('
|
|
13
|
-
HTTP_TIMEOUT: z.string().default('
|
|
11
|
+
NAVIGATION_TIMEOUT: z.string().default('45000'),
|
|
12
|
+
PAGE_TIMEOUT: z.string().default('30000'),
|
|
13
|
+
HTTP_TIMEOUT: z.string().default('30000'),
|
|
14
|
+
HEADERS_TIMEOUT: z.string().default('60000'),
|
|
14
15
|
CHAT_TIMEOUT: z.string().default('120000'),
|
|
16
|
+
STREAM_IDLE_TIMEOUT: z.string().default('180000'),
|
|
15
17
|
CACHE_TTL: z.string().default('3600'),
|
|
16
18
|
RESPONSE_TTL: z.string().default('1800'),
|
|
17
19
|
METRICS_INTERVAL: z.string().default('10000'),
|
|
@@ -59,7 +61,9 @@ export const config = {
|
|
|
59
61
|
navigation: parseInt(env.NAVIGATION_TIMEOUT),
|
|
60
62
|
page: parseInt(env.PAGE_TIMEOUT),
|
|
61
63
|
http: parseInt(env.HTTP_TIMEOUT),
|
|
64
|
+
headers: parseInt(env.HEADERS_TIMEOUT),
|
|
62
65
|
chat: parseInt(env.CHAT_TIMEOUT),
|
|
66
|
+
streamIdle: parseInt(env.STREAM_IDLE_TIMEOUT),
|
|
63
67
|
},
|
|
64
68
|
cache: {
|
|
65
69
|
defaultTTL: parseInt(env.CACHE_TTL),
|
package/src/routes/chat.ts
CHANGED
|
@@ -128,14 +128,217 @@ function parseQwenErrorPayload(raw: string): { message: string; status: number }
|
|
|
128
128
|
return { message: `Qwen upstream error: ${msg}`, status: 502 };
|
|
129
129
|
}
|
|
130
130
|
} catch {
|
|
131
|
-
// Non-SSE, non-JSON upstream body. Keep this as an explicit bad gateway
|
|
132
|
-
// instead of silently returning an empty assistant message.
|
|
133
131
|
return { message: `Qwen upstream returned non-SSE response: ${text.slice(0, 300)}`, status: 502 };
|
|
134
132
|
}
|
|
135
133
|
|
|
136
134
|
return null;
|
|
137
135
|
}
|
|
138
136
|
|
|
137
|
+
function getToolFunction(tool: FunctionToolDefinition | any): any {
|
|
138
|
+
return tool?.type === 'function' ? tool.function : tool;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getToolName(tool: FunctionToolDefinition | any): string {
|
|
142
|
+
return getToolFunction(tool)?.name || '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getToolDescription(tool: FunctionToolDefinition | any): string {
|
|
146
|
+
return getToolFunction(tool)?.description || '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getToolParameters(tool: FunctionToolDefinition | any): Record<string, any> {
|
|
150
|
+
return getToolFunction(tool)?.parameters?.properties || {};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getRequiredParams(tool: FunctionToolDefinition | any): Set<string> {
|
|
154
|
+
return new Set(getToolFunction(tool)?.parameters?.required || []);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function compactPromptText(text: string, maxChars = 180): string {
|
|
158
|
+
const compact = text.replace(/\s+/g, ' ').trim();
|
|
159
|
+
if (compact.length <= maxChars) return compact;
|
|
160
|
+
return `${compact.slice(0, maxChars)}...`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getForcedToolName(toolChoice: any): string {
|
|
164
|
+
if (toolChoice && typeof toolChoice === 'object' && toolChoice.function?.name) {
|
|
165
|
+
return toolChoice.function.name;
|
|
166
|
+
}
|
|
167
|
+
return '';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function tokenizeForToolScoring(text: string): Set<string> {
|
|
171
|
+
const tokens = new Set<string>();
|
|
172
|
+
for (const token of text.toLowerCase().match(/[a-z0-9_./-]+/g) || []) {
|
|
173
|
+
if (token.length >= 3) tokens.add(token);
|
|
174
|
+
}
|
|
175
|
+
return tokens;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function scoreToolForContext(tool: FunctionToolDefinition, contextText: string, forcedToolName: string, recentToolNames: Set<string>): number {
|
|
179
|
+
const name = getToolName(tool);
|
|
180
|
+
const description = getToolDescription(tool);
|
|
181
|
+
const params = Object.keys(getToolParameters(tool));
|
|
182
|
+
const tokens = tokenizeForToolScoring(contextText);
|
|
183
|
+
let score = 0;
|
|
184
|
+
|
|
185
|
+
if (forcedToolName && name === forcedToolName) score += 100;
|
|
186
|
+
if (recentToolNames.has(name)) score += 35;
|
|
187
|
+
|
|
188
|
+
const nameParts = name.toLowerCase().split(/[_./-]+/).filter(Boolean);
|
|
189
|
+
for (const part of nameParts) {
|
|
190
|
+
if (part.length >= 3 && tokens.has(part)) score += 20;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const toolText = `${name} ${description} ${params.join(' ')}`.toLowerCase();
|
|
194
|
+
for (const token of tokens) {
|
|
195
|
+
if (toolText.includes(token)) score += 2;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const param of params) {
|
|
199
|
+
if (tokens.has(param.toLowerCase())) score += 3;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return score;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getRecentToolNames(messages: Message[]): Set<string> {
|
|
206
|
+
const recentToolNames = new Set<string>();
|
|
207
|
+
const recentMessages = messages.slice(-12);
|
|
208
|
+
|
|
209
|
+
for (const msg of recentMessages) {
|
|
210
|
+
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) {
|
|
211
|
+
for (const call of msg.tool_calls) {
|
|
212
|
+
if (call?.function?.name) recentToolNames.add(call.function.name);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if ((msg.role === 'tool' || msg.role === 'function') && msg.name) {
|
|
216
|
+
recentToolNames.add(msg.name);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return recentToolNames;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function selectCandidateTools(
|
|
224
|
+
tools: FunctionToolDefinition[],
|
|
225
|
+
contextText: string,
|
|
226
|
+
forcedToolName = '',
|
|
227
|
+
recentToolNames: Set<string> = new Set(),
|
|
228
|
+
maxTools = 12
|
|
229
|
+
): FunctionToolDefinition[] {
|
|
230
|
+
if (tools.length <= maxTools) return tools;
|
|
231
|
+
|
|
232
|
+
const scored = tools
|
|
233
|
+
.map(tool => ({ tool, score: scoreToolForContext(tool, contextText, forcedToolName, recentToolNames) }))
|
|
234
|
+
.filter(entry => entry.score > 0 || (forcedToolName && getToolName(entry.tool) === forcedToolName))
|
|
235
|
+
.sort((a, b) => b.score - a.score || getToolName(a.tool).localeCompare(getToolName(b.tool)));
|
|
236
|
+
|
|
237
|
+
if (scored.length === 0) {
|
|
238
|
+
return tools.slice(0, maxTools);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return scored.slice(0, maxTools).map(entry => entry.tool);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildCompactToolManifest(tools: FunctionToolDefinition[], forcedToolName = ''): string {
|
|
245
|
+
if (tools.length === 0) return '';
|
|
246
|
+
|
|
247
|
+
const lines = tools.map(tool => {
|
|
248
|
+
const name = getToolName(tool);
|
|
249
|
+
const description = compactPromptText(getToolDescription(tool), 140);
|
|
250
|
+
const params = getToolParameters(tool);
|
|
251
|
+
const required = getRequiredParams(tool);
|
|
252
|
+
const signature = Object.entries(params)
|
|
253
|
+
.map(([paramName, schema]: [string, any]) => {
|
|
254
|
+
const optional = required.has(paramName) ? '' : '?';
|
|
255
|
+
const type = schema?.type || 'any';
|
|
256
|
+
return `${paramName}${optional}: ${type}`;
|
|
257
|
+
})
|
|
258
|
+
.join(', ');
|
|
259
|
+
|
|
260
|
+
const marker = forcedToolName && name === forcedToolName ? ' [required]' : '';
|
|
261
|
+
return `${name}(${signature})${description ? ` - ${description}` : ''}${marker}`;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return `[COMPACT TOOL MANIFEST]\n${lines.join('\n')}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildToolCallContract(
|
|
268
|
+
tools: FunctionToolDefinition[],
|
|
269
|
+
forcedToolName = '',
|
|
270
|
+
parallelToolCalls = true
|
|
271
|
+
): string {
|
|
272
|
+
const names = tools.map(getToolName).filter(Boolean);
|
|
273
|
+
const toolList = names.length > 0 ? names.join(', ') : 'none';
|
|
274
|
+
const forcedLine = forcedToolName
|
|
275
|
+
? `This turn strongly expects the tool "${forcedToolName}". If you call a tool, prefer this exact name.`
|
|
276
|
+
: 'Only call a tool when the user request requires an external action.';
|
|
277
|
+
const parallelLine = parallelToolCalls
|
|
278
|
+
? 'You may emit multiple tool call blocks only when the user explicitly asks for multiple independent actions.'
|
|
279
|
+
: 'Emit at most one tool call block.';
|
|
280
|
+
|
|
281
|
+
return `[TOOL CALL CONTRACT - MUST FOLLOW]
|
|
282
|
+
Available tool names: ${toolList}
|
|
283
|
+
Format:
|
|
284
|
+
|
|
285
|
+
<tool_call>
|
|
286
|
+
{"name": "tool_name", "arguments": {"param_name": "value"}}
|
|
287
|
+
</tool_call>
|
|
288
|
+
|
|
289
|
+
Rules:
|
|
290
|
+
1. Use exact tool names from the list above or the full TOOLS AVAILABLE section.
|
|
291
|
+
2. Do not invent, guess, rename, or approximate tool names.
|
|
292
|
+
3. Do not output raw JSON as a tool call.
|
|
293
|
+
4. ${forcedLine}
|
|
294
|
+
5. ${parallelLine}
|
|
295
|
+
6. If no tool is needed, do not emit any tool call block.`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseToolArguments(value: unknown): Record<string, unknown> {
|
|
299
|
+
if (typeof value === 'string') {
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(value);
|
|
302
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
303
|
+
} catch {
|
|
304
|
+
return {};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
308
|
+
return value as Record<string, unknown>;
|
|
309
|
+
}
|
|
310
|
+
return {};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function looksLikeUnwrappedToolCall(text: string): boolean {
|
|
314
|
+
const trimmed = text.trim();
|
|
315
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
|
|
316
|
+
return /["']name["']\s*:/.test(trimmed) && /["']arguments["']\s*:/.test(trimmed);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseUnwrappedToolCalls(text: string): Array<{ id: string; name: string; arguments: Record<string, unknown> }> {
|
|
320
|
+
if (!looksLikeUnwrappedToolCall(text)) return [];
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const parsed = robustParseJSON(text);
|
|
324
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
325
|
+
return items
|
|
326
|
+
.filter(item => item && typeof item === 'object')
|
|
327
|
+
.map((item: any) => {
|
|
328
|
+
const name = item.name || item.function?.name || item.tool_name || item.tool;
|
|
329
|
+
if (!name || typeof name !== 'string') return null;
|
|
330
|
+
return {
|
|
331
|
+
id: item.id || item.tool_call_id || `call_${crypto.randomUUID()}`,
|
|
332
|
+
name,
|
|
333
|
+
arguments: parseToolArguments(item.arguments || item.function?.arguments || item.args || item.parameters || item.input || {}),
|
|
334
|
+
};
|
|
335
|
+
})
|
|
336
|
+
.filter((item: any): item is { id: string; name: string; arguments: Record<string, unknown> } => item !== null);
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
139
342
|
export async function chatCompletions(c: Context) {
|
|
140
343
|
try {
|
|
141
344
|
const body: OpenAIRequest = await c.req.json();
|
|
@@ -250,6 +453,11 @@ export async function chatCompletions(c: Context) {
|
|
|
250
453
|
const modelContextWindow = getModelContextWindow(modelId)
|
|
251
454
|
const estimatedTokens = estimateTokenCount(systemPrompt + prompt, modelId);
|
|
252
455
|
const hasTools = Array.isArray(bodyAny.tools) && bodyAny.tools.length > 0;
|
|
456
|
+
const forcedToolName = getForcedToolName(bodyAny.tool_choice);
|
|
457
|
+
const parallelToolCalls = bodyAny.parallel_tool_calls !== false;
|
|
458
|
+
const toolContextText = `${systemPrompt}\n${prompt}`;
|
|
459
|
+
const recentToolNames = hasTools ? getRecentToolNames(messages) : new Set<string>();
|
|
460
|
+
const candidateTools = hasTools ? selectCandidateTools(bodyAny.tools, toolContextText, forcedToolName, recentToolNames) : [];
|
|
253
461
|
|
|
254
462
|
let finalPrompt: string;
|
|
255
463
|
if (estimatedTokens > modelContextWindow - 1000) {
|
|
@@ -260,9 +468,11 @@ export async function chatCompletions(c: Context) {
|
|
|
260
468
|
finalPrompt = systemPrompt ? `${systemPrompt}\n${prompt}` : prompt;
|
|
261
469
|
}
|
|
262
470
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
471
|
+
if (hasTools) {
|
|
472
|
+
const compactManifest = buildCompactToolManifest(candidateTools, forcedToolName);
|
|
473
|
+
const toolContract = buildToolCallContract(candidateTools, forcedToolName, parallelToolCalls);
|
|
474
|
+
finalPrompt += `\n\n${toolContract}`;
|
|
475
|
+
if (compactManifest) finalPrompt += `\n\n${compactManifest}`;
|
|
266
476
|
}
|
|
267
477
|
|
|
268
478
|
const isThinkingModel = !body.model.includes('no-thinking');
|
|
@@ -498,6 +708,20 @@ export async function chatCompletions(c: Context) {
|
|
|
498
708
|
});
|
|
499
709
|
}
|
|
500
710
|
|
|
711
|
+
if (hasTools && toolCallsOut.length === 0) {
|
|
712
|
+
for (const tc of parseUnwrappedToolCalls(finalContent)) {
|
|
713
|
+
toolCallsOut.push({
|
|
714
|
+
id: tc.id,
|
|
715
|
+
type: 'function',
|
|
716
|
+
function: {
|
|
717
|
+
name: tc.name,
|
|
718
|
+
arguments: JSON.stringify(tc.arguments)
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (toolCallsOut.length > 0) finalContent = '';
|
|
723
|
+
}
|
|
724
|
+
|
|
501
725
|
const usage = {
|
|
502
726
|
prompt_tokens: parserState.promptTokens,
|
|
503
727
|
completion_tokens: parserState.completionTokens,
|
|
@@ -687,7 +911,32 @@ export async function chatCompletions(c: Context) {
|
|
|
687
911
|
if (hasTools && toolParser) {
|
|
688
912
|
const { text, toolCalls } = toolParser.feed(vStr);
|
|
689
913
|
if (text) {
|
|
690
|
-
|
|
914
|
+
if (hasTools && toolParser && looksLikeUnwrappedToolCall(text)) {
|
|
915
|
+
const unwrappedToolCalls = parseUnwrappedToolCalls(text);
|
|
916
|
+
const baseIndex = toolParser.getEmittedToolCallCount();
|
|
917
|
+
for (let idx = 0; idx < unwrappedToolCalls.length; idx++) {
|
|
918
|
+
const tc = unwrappedToolCalls[idx];
|
|
919
|
+
streamWriter.write(`data: ${JSON.stringify({
|
|
920
|
+
id: completionId,
|
|
921
|
+
object: 'chat.completion.chunk',
|
|
922
|
+
created: createdTimestamp,
|
|
923
|
+
model: body.model,
|
|
924
|
+
choices: [makeChoice({
|
|
925
|
+
tool_calls: [{
|
|
926
|
+
index: baseIndex + idx,
|
|
927
|
+
id: tc.id,
|
|
928
|
+
type: 'function',
|
|
929
|
+
function: {
|
|
930
|
+
name: tc.name,
|
|
931
|
+
arguments: JSON.stringify(tc.arguments)
|
|
932
|
+
}
|
|
933
|
+
}]
|
|
934
|
+
})]
|
|
935
|
+
})}\n\n`);
|
|
936
|
+
}
|
|
937
|
+
} else {
|
|
938
|
+
fastWriteContent(text);
|
|
939
|
+
}
|
|
691
940
|
}
|
|
692
941
|
for (const tc of toolCalls) {
|
|
693
942
|
streamWriter.write(`data: ${JSON.stringify({
|
|
@@ -753,13 +1002,38 @@ export async function chatCompletions(c: Context) {
|
|
|
753
1002
|
const flushResult = toolParser.flush();
|
|
754
1003
|
|
|
755
1004
|
if (flushResult.text) {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1005
|
+
if (hasTools && toolParser && looksLikeUnwrappedToolCall(flushResult.text)) {
|
|
1006
|
+
const unwrappedToolCalls = parseUnwrappedToolCalls(flushResult.text);
|
|
1007
|
+
const baseIndex = toolParser.getEmittedToolCallCount();
|
|
1008
|
+
for (let idx = 0; idx < unwrappedToolCalls.length; idx++) {
|
|
1009
|
+
const tc = unwrappedToolCalls[idx];
|
|
1010
|
+
writeEvent({
|
|
1011
|
+
id: completionId,
|
|
1012
|
+
object: 'chat.completion.chunk',
|
|
1013
|
+
created: createdTimestamp,
|
|
1014
|
+
model: body.model,
|
|
1015
|
+
choices: [makeChoice({
|
|
1016
|
+
tool_calls: [{
|
|
1017
|
+
index: baseIndex + idx,
|
|
1018
|
+
id: tc.id,
|
|
1019
|
+
type: 'function',
|
|
1020
|
+
function: {
|
|
1021
|
+
name: tc.name,
|
|
1022
|
+
arguments: JSON.stringify(tc.arguments)
|
|
1023
|
+
}
|
|
1024
|
+
}]
|
|
1025
|
+
})]
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
} else {
|
|
1029
|
+
writeEvent({
|
|
1030
|
+
id: completionId,
|
|
1031
|
+
object: 'chat.completion.chunk',
|
|
1032
|
+
created: createdTimestamp,
|
|
1033
|
+
model: body.model,
|
|
1034
|
+
choices: [makeChoice({ content: flushResult.text })]
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
763
1037
|
}
|
|
764
1038
|
for (const tc of flushResult.toolCalls) {
|
|
765
1039
|
const idx = toolParser.getEmittedToolCallCount() - flushResult.toolCalls.length + flushResult.toolCalls.indexOf(tc);
|
|
@@ -385,7 +385,7 @@ async function checkValidSession(): Promise<boolean> {
|
|
|
385
385
|
const cookies = await activePage.context().cookies();
|
|
386
386
|
const hasAuthCookie = cookies.some(c => c.name.toLowerCase().includes('token') || c.name.toLowerCase().includes('session'));
|
|
387
387
|
if (!hasAuthCookie) return false;
|
|
388
|
-
await activePage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded', timeout:
|
|
388
|
+
await activePage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded', timeout: config.timeouts.navigation });
|
|
389
389
|
const isLogged = !activePage.url().includes('auth') && !activePage.url().includes('login');
|
|
390
390
|
return isLogged;
|
|
391
391
|
} catch {
|
|
@@ -450,7 +450,7 @@ async function loginToQwenUI(email: string, password: string): Promise<boolean>
|
|
|
450
450
|
}
|
|
451
451
|
|
|
452
452
|
try {
|
|
453
|
-
await activePage.waitForSelector('input[type="email"], input[placeholder*="Email"]', { timeout:
|
|
453
|
+
await activePage.waitForSelector('input[type="email"], input[placeholder*="Email"]', { timeout: config.timeouts.page });
|
|
454
454
|
} catch {
|
|
455
455
|
if (activePage.url().includes('/auth')) throw new Error('Email input not found');
|
|
456
456
|
console.log('[Playwright] Already logged in');
|
|
@@ -462,7 +462,7 @@ async function loginToQwenUI(email: string, password: string): Promise<boolean>
|
|
|
462
462
|
await activePage.keyboard.press('Enter');
|
|
463
463
|
await sleep(1000);
|
|
464
464
|
|
|
465
|
-
await activePage.waitForSelector('input[type="password"]', { timeout:
|
|
465
|
+
await activePage.waitForSelector('input[type="password"]', { timeout: config.timeouts.page });
|
|
466
466
|
console.log('[Playwright] UI: Filling password...');
|
|
467
467
|
await activePage.fill('input[type="password"]', password);
|
|
468
468
|
await activePage.keyboard.press('Enter');
|
|
@@ -502,7 +502,7 @@ export async function getGuestHeaders(): Promise<Record<string, string>> {
|
|
|
502
502
|
await guestContext.addInitScript(getStealthScript());
|
|
503
503
|
guestPage = await guestContext.newPage();
|
|
504
504
|
|
|
505
|
-
await guestPage.goto('https://chat.qwen.ai/c/guest', { waitUntil: 'domcontentloaded' });
|
|
505
|
+
await guestPage.goto('https://chat.qwen.ai/c/guest', { waitUntil: 'domcontentloaded', timeout: config.timeouts.navigation });
|
|
506
506
|
|
|
507
507
|
try {
|
|
508
508
|
const keepSessionBtn = await guestPage.$('button:has-text("Manter sessão terminada"), button:has-text("Keep session ended"), button:has-text("Manter sessão encerrada")');
|
|
@@ -517,7 +517,7 @@ export async function getGuestHeaders(): Promise<Record<string, string>> {
|
|
|
517
517
|
}
|
|
518
518
|
|
|
519
519
|
return new Promise((resolve, reject) => {
|
|
520
|
-
const timeout = setTimeout(() => reject(new Error('Timeout getting guest headers')),
|
|
520
|
+
const timeout = setTimeout(() => reject(new Error('Timeout getting guest headers')), config.timeouts.headers);
|
|
521
521
|
|
|
522
522
|
const routeHandler = async (route: any, request: any) => {
|
|
523
523
|
clearTimeout(timeout);
|
|
@@ -558,7 +558,7 @@ export async function getGuestHeaders(): Promise<Record<string, string>> {
|
|
|
558
558
|
guestPage!.route('**/api/v2/chat/completions*', routeHandler).then(async () => {
|
|
559
559
|
const inputSelector = 'textarea:visible, [contenteditable="true"]:visible';
|
|
560
560
|
try {
|
|
561
|
-
await guestPage!.waitForSelector(inputSelector, { timeout:
|
|
561
|
+
await guestPage!.waitForSelector(inputSelector, { timeout: config.timeouts.page });
|
|
562
562
|
await guestPage!.focus(inputSelector);
|
|
563
563
|
await guestPage!.fill(inputSelector, '');
|
|
564
564
|
await guestPage!.type(inputSelector, 'a', { delay: 50 });
|
|
@@ -751,7 +751,7 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
|
|
|
751
751
|
|
|
752
752
|
console.log(`[Playwright] Waiting for chat input for ${cacheKey}...`);
|
|
753
753
|
const inputSelector = 'textarea:visible, [contenteditable="true"]:visible';
|
|
754
|
-
await page.waitForSelector(inputSelector, { timeout:
|
|
754
|
+
await page.waitForSelector(inputSelector, { timeout: config.timeouts.page }).catch(() => {
|
|
755
755
|
console.error(`[Playwright] Chat input not found for ${cacheKey}. Current URL:`, page.url());
|
|
756
756
|
throw new Error(`Timeout waiting for chat input for ${cacheKey}. Are you logged in?`);
|
|
757
757
|
});
|
|
@@ -767,12 +767,10 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
|
|
|
767
767
|
console.error('[Playwright] Failed to save error screenshot:', err.message);
|
|
768
768
|
}
|
|
769
769
|
reject(new Error(`Timeout waiting for Qwen headers for ${cacheKey}`));
|
|
770
|
-
},
|
|
770
|
+
}, config.timeouts.headers);
|
|
771
771
|
|
|
772
772
|
console.log(`[Playwright] Setting up route interception for ${cacheKey}...`);
|
|
773
773
|
const routeHandler = async (route: any, request: any) => {
|
|
774
|
-
clearTimeout(timeout);
|
|
775
|
-
|
|
776
774
|
const reqHeaders = request.headers();
|
|
777
775
|
let uiSessionId = '';
|
|
778
776
|
let uiParentMessageId: string | null = null;
|
|
@@ -806,6 +804,8 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
|
|
|
806
804
|
return;
|
|
807
805
|
}
|
|
808
806
|
|
|
807
|
+
clearTimeout(timeout);
|
|
808
|
+
|
|
809
809
|
console.log(`[Playwright] Successfully intercepted headers for ${cacheKey}.`);
|
|
810
810
|
cache.currentHeaders = extractedHeaders;
|
|
811
811
|
cache.cachedQwenHeaders = { headers: extractedHeaders, chatSessionId: uiSessionId, parentMessageId: uiParentMessageId };
|
|
@@ -906,13 +906,13 @@ export async function initPlaywrightForAccount(account: QwenAccount, headless =
|
|
|
906
906
|
|
|
907
907
|
// Navigate to Qwen home to validate session and populate cookies
|
|
908
908
|
try {
|
|
909
|
-
await acctPage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded', timeout:
|
|
909
|
+
await acctPage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded', timeout: config.timeouts.navigation });
|
|
910
910
|
const url = acctPage.url();
|
|
911
911
|
if (url.includes('auth') || url.includes('login')) {
|
|
912
912
|
if (account.email && account.password) {
|
|
913
913
|
console.log(`[Playwright] Session expired for ${account.email}, re-logging in...`);
|
|
914
914
|
await loginToQwenWithContext(acctContext, acctPage, account.email, account.password);
|
|
915
|
-
await acctPage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded', timeout:
|
|
915
|
+
await acctPage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded', timeout: config.timeouts.navigation });
|
|
916
916
|
} else {
|
|
917
917
|
console.warn(`[Playwright] Session expired for account ${account.id} but no credentials available for re-login.`);
|
|
918
918
|
}
|
|
@@ -1114,15 +1114,18 @@ export async function browserStreamFetch(
|
|
|
1114
1114
|
const enc = new TextEncoder();
|
|
1115
1115
|
|
|
1116
1116
|
let metaResolve!: (value: { status: number; statusText: string; contentType: string; headers: Record<string, string> }) => void;
|
|
1117
|
-
|
|
1117
|
+
let metaReject!: (reason: Error) => void;
|
|
1118
|
+
const metaPromise = new Promise<{ status: number; statusText: string; contentType: string; headers: Record<string, string> }>((resolve, reject) => {
|
|
1118
1119
|
metaResolve = resolve;
|
|
1120
|
+
metaReject = reject;
|
|
1119
1121
|
});
|
|
1120
1122
|
|
|
1123
|
+
const metaTimeoutMs = options.timeoutMs || config.timeouts.chat;
|
|
1121
1124
|
const metaTimeout = setTimeout(() => {
|
|
1122
1125
|
streamCallbacks.delete(reqId);
|
|
1123
1126
|
abortControllers.delete(reqId);
|
|
1124
|
-
|
|
1125
|
-
},
|
|
1127
|
+
metaReject(new Error(`Browser stream fetch timed out waiting for response metadata after ${metaTimeoutMs}ms`));
|
|
1128
|
+
}, metaTimeoutMs);
|
|
1126
1129
|
|
|
1127
1130
|
streamCallbacks.set(reqId, {
|
|
1128
1131
|
onMeta: (meta) => {
|
|
@@ -1131,13 +1134,20 @@ export async function browserStreamFetch(
|
|
|
1131
1134
|
},
|
|
1132
1135
|
onChunk: () => {},
|
|
1133
1136
|
onEnd: () => {},
|
|
1134
|
-
onError: () => {
|
|
1137
|
+
onError: (msg: string) => {
|
|
1138
|
+
clearTimeout(metaTimeout);
|
|
1139
|
+
metaReject(new Error(msg));
|
|
1140
|
+
},
|
|
1135
1141
|
onBody: () => {},
|
|
1136
1142
|
});
|
|
1137
1143
|
|
|
1138
1144
|
let abortFn = () => {};
|
|
1139
1145
|
let bodyResolve!: (value: string) => void;
|
|
1140
|
-
|
|
1146
|
+
let bodyReject!: (reason: Error) => void;
|
|
1147
|
+
const bodyPromise = new Promise<string>((resolve, reject) => {
|
|
1148
|
+
bodyResolve = resolve;
|
|
1149
|
+
bodyReject = reject;
|
|
1150
|
+
});
|
|
1141
1151
|
|
|
1142
1152
|
const stream = new ReadableStream<Uint8Array>({
|
|
1143
1153
|
start(controller) {
|
|
@@ -1148,11 +1158,13 @@ export async function browserStreamFetch(
|
|
|
1148
1158
|
};
|
|
1149
1159
|
cb.onEnd = () => {
|
|
1150
1160
|
try { controller.close(); } catch {}
|
|
1161
|
+
bodyResolve('');
|
|
1151
1162
|
streamCallbacks.delete(reqId);
|
|
1152
1163
|
abortControllers.delete(reqId);
|
|
1153
1164
|
};
|
|
1154
1165
|
cb.onError = (msg: string) => {
|
|
1155
1166
|
try { controller.error(new Error(msg)); } catch {}
|
|
1167
|
+
bodyReject(new Error(msg));
|
|
1156
1168
|
streamCallbacks.delete(reqId);
|
|
1157
1169
|
abortControllers.delete(reqId);
|
|
1158
1170
|
};
|
|
@@ -1166,7 +1178,7 @@ export async function browserStreamFetch(
|
|
|
1166
1178
|
const controller = new AbortController();
|
|
1167
1179
|
(window as any).__abortControllers = (window as any).__abortControllers || {};
|
|
1168
1180
|
(window as any).__abortControllers[reqId] = controller;
|
|
1169
|
-
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs ||
|
|
1181
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs || config.timeouts.chat);
|
|
1170
1182
|
try {
|
|
1171
1183
|
const resp = await fetch(url, {
|
|
1172
1184
|
method: options.method || 'POST',
|
package/src/services/qwen.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getQwenHeaders, getBasicHeaders, getGuestHeaders, getPageForAccount, browserFetch, browserStreamFetch, CHROME_CLIENT_HINTS, CHROME_UA } from './playwright.js';
|
|
2
2
|
import { MAX_PAYLOAD_SIZE } from '../core/model-registry.js';
|
|
3
3
|
import { markAccountRateLimited } from '../core/account-manager.js';
|
|
4
|
+
import { config } from '../core/config.js';
|
|
4
5
|
import crypto from 'crypto';
|
|
5
6
|
|
|
6
7
|
const CACHED_TIMEZONE = new Date().toString().split(' (')[0];
|
|
@@ -9,6 +10,69 @@ const TIMEOUT_PER_MB = 30000;
|
|
|
9
10
|
|
|
10
11
|
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
11
12
|
|
|
13
|
+
function addIdleTimeoutToStream(
|
|
14
|
+
stream: ReadableStream<Uint8Array>,
|
|
15
|
+
controller: AbortController,
|
|
16
|
+
idleTimeoutMs: number,
|
|
17
|
+
label: string,
|
|
18
|
+
onTimeout?: () => void,
|
|
19
|
+
onDone?: () => void,
|
|
20
|
+
): ReadableStream<Uint8Array> {
|
|
21
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
22
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
23
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
24
|
+
|
|
25
|
+
const clearIdleTimer = () => {
|
|
26
|
+
if (idleTimer) {
|
|
27
|
+
clearTimeout(idleTimer);
|
|
28
|
+
idleTimer = undefined;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const resetIdleTimer = () => {
|
|
33
|
+
clearIdleTimer();
|
|
34
|
+
idleTimer = setTimeout(() => {
|
|
35
|
+
const message = `${label} idle timeout after ${idleTimeoutMs}ms without upstream data`;
|
|
36
|
+
const timeoutError = new Error(message);
|
|
37
|
+
clearIdleTimer();
|
|
38
|
+
controller.abort();
|
|
39
|
+
streamController?.error(timeoutError);
|
|
40
|
+
onTimeout?.();
|
|
41
|
+
try { stream.cancel(message).catch(() => {}); } catch {}
|
|
42
|
+
}, idleTimeoutMs);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return new ReadableStream<Uint8Array>({
|
|
46
|
+
start() {
|
|
47
|
+
reader = stream.getReader();
|
|
48
|
+
resetIdleTimer();
|
|
49
|
+
},
|
|
50
|
+
async pull(streamController) {
|
|
51
|
+
try {
|
|
52
|
+
if (!reader) throw new Error('Stream reader was not initialized');
|
|
53
|
+
const { done, value } = await reader.read();
|
|
54
|
+
if (done) {
|
|
55
|
+
clearIdleTimer();
|
|
56
|
+
onDone?.();
|
|
57
|
+
streamController.close();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
resetIdleTimer();
|
|
61
|
+
streamController.enqueue(value);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
clearIdleTimer();
|
|
64
|
+
onDone?.();
|
|
65
|
+
streamController.error(err);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
cancel(reason) {
|
|
69
|
+
clearIdleTimer();
|
|
70
|
+
onDone?.();
|
|
71
|
+
return stream.cancel(reason);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
12
76
|
function getClientHintsHeaders(): Record<string, string> {
|
|
13
77
|
return {
|
|
14
78
|
'sec-ch-ua': CHROME_CLIENT_HINTS,
|
|
@@ -82,6 +146,8 @@ interface WarmPoolEntry {
|
|
|
82
146
|
|
|
83
147
|
const warmPool: Map<string, WarmPoolEntry[]> = new Map();
|
|
84
148
|
|
|
149
|
+
const inFlightWarmChats = new Set<string>();
|
|
150
|
+
|
|
85
151
|
const refillPromises: Map<string, Promise<void>> = new Map();
|
|
86
152
|
|
|
87
153
|
const WARM_POOL_SIZE = 10;
|
|
@@ -96,6 +162,22 @@ function cleanupStalePool(accountId: string) {
|
|
|
96
162
|
if (filtered.length !== pool.length) warmPool.set(accountId, filtered);
|
|
97
163
|
}
|
|
98
164
|
|
|
165
|
+
function warmChatKey(accountId: string, chatId: string) {
|
|
166
|
+
return `${accountId}:${chatId}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function markWarmChatInFlight(accountId: string, chatId: string) {
|
|
170
|
+
inFlightWarmChats.add(warmChatKey(accountId, chatId));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function releaseWarmChat(accountId: string, chatId: string) {
|
|
174
|
+
inFlightWarmChats.delete(warmChatKey(accountId, chatId));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isWarmChatInFlight(accountId: string, chatId: string) {
|
|
178
|
+
return inFlightWarmChats.has(warmChatKey(accountId, chatId));
|
|
179
|
+
}
|
|
180
|
+
|
|
99
181
|
async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, string>> {
|
|
100
182
|
const { cookie, userAgent, bxV, bxUa, bxUmidtoken } = await getBasicHeaders(accountId);
|
|
101
183
|
return {
|
|
@@ -132,7 +214,7 @@ async function createRealQwenChat(header: Record<string, string>, accountId?: st
|
|
|
132
214
|
'timezone': CACHED_TIMEZONE,
|
|
133
215
|
},
|
|
134
216
|
body,
|
|
135
|
-
timeoutMs:
|
|
217
|
+
timeoutMs: config.timeouts.http,
|
|
136
218
|
});
|
|
137
219
|
|
|
138
220
|
if (result.status === 429) {
|
|
@@ -176,7 +258,7 @@ async function createRealQwenChat(header: Record<string, string>, accountId?: st
|
|
|
176
258
|
...getClientHintsHeaders(),
|
|
177
259
|
},
|
|
178
260
|
body,
|
|
179
|
-
signal: AbortSignal.timeout(
|
|
261
|
+
signal: AbortSignal.timeout(config.timeouts.http),
|
|
180
262
|
});
|
|
181
263
|
|
|
182
264
|
if (!response.ok) {
|
|
@@ -200,6 +282,69 @@ async function createRealQwenChat(header: Record<string, string>, accountId?: st
|
|
|
200
282
|
return chatId;
|
|
201
283
|
}
|
|
202
284
|
|
|
285
|
+
async function fetchUnusedChats(headers: Record<string, string>, accountId?: string): Promise<string[]> {
|
|
286
|
+
const page = getPageForAccount(accountId);
|
|
287
|
+
const url = 'https://chat.qwen.ai/api/v2/chats/?page=1&exclude_project=true';
|
|
288
|
+
const reqHeaders: Record<string, string> = {
|
|
289
|
+
'accept': 'application/json, text/plain, */*',
|
|
290
|
+
'x-request-id': crypto.randomUUID(),
|
|
291
|
+
'timezone': CACHED_TIMEZONE,
|
|
292
|
+
'source': 'web',
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
let body = '';
|
|
296
|
+
if (page && !page.isClosed() && page.url().includes('chat.qwen.ai')) {
|
|
297
|
+
try {
|
|
298
|
+
const result = await browserFetch(page, url, {
|
|
299
|
+
method: 'GET',
|
|
300
|
+
headers: reqHeaders,
|
|
301
|
+
timeoutMs: config.timeouts.http,
|
|
302
|
+
});
|
|
303
|
+
if (result.status && result.status < 400) {
|
|
304
|
+
body = result.body;
|
|
305
|
+
}
|
|
306
|
+
} catch (err: any) {
|
|
307
|
+
console.warn('[WarmPool] browserFetch failed for chat list, falling back:', err.message);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!body) {
|
|
312
|
+
const response = await fetch(url, {
|
|
313
|
+
headers: {
|
|
314
|
+
'accept': 'application/json, text/plain, */*',
|
|
315
|
+
'accept-language': 'pt-BR,pt;q=0.9',
|
|
316
|
+
'cookie': headers['cookie'],
|
|
317
|
+
'referer': 'https://chat.qwen.ai/',
|
|
318
|
+
'user-agent': headers['user-agent'],
|
|
319
|
+
'x-request-id': crypto.randomUUID(),
|
|
320
|
+
'bx-v': headers['bx-v'],
|
|
321
|
+
'bx-ua': headers['bx-ua'] || '',
|
|
322
|
+
'bx-umidtoken': headers['bx-umidtoken'] || '',
|
|
323
|
+
'timezone': CACHED_TIMEZONE,
|
|
324
|
+
'source': 'web',
|
|
325
|
+
...getClientHintsHeaders(),
|
|
326
|
+
},
|
|
327
|
+
signal: AbortSignal.timeout(config.timeouts.http),
|
|
328
|
+
});
|
|
329
|
+
if (!response.ok) return [];
|
|
330
|
+
body = await response.text();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const json = JSON.parse(body);
|
|
335
|
+
if (!json.success || !Array.isArray(json.data)) return [];
|
|
336
|
+
const unused: string[] = [];
|
|
337
|
+
for (const chat of json.data) {
|
|
338
|
+
if (chat.title === 'Nova Conversa' && chat.created_at === chat.updated_at) {
|
|
339
|
+
unused.push(chat.id);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return unused;
|
|
343
|
+
} catch {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
203
348
|
async function refillPoolForAccount(accountId: string) {
|
|
204
349
|
let pool = warmPool.get(accountId);
|
|
205
350
|
if (!pool) { pool = []; warmPool.set(accountId, pool); }
|
|
@@ -217,7 +362,28 @@ async function refillPoolForAccount(accountId: string) {
|
|
|
217
362
|
}
|
|
218
363
|
|
|
219
364
|
const acctId = accountId === 'global' ? undefined : accountId;
|
|
220
|
-
|
|
365
|
+
const existingIds = new Set(pool.map(e => e.chatId));
|
|
366
|
+
|
|
367
|
+
let reused = 0;
|
|
368
|
+
try {
|
|
369
|
+
const unusedChats = await fetchUnusedChats(headers, acctId);
|
|
370
|
+
for (const chatId of unusedChats) {
|
|
371
|
+
if (reused >= need) break;
|
|
372
|
+
if (existingIds.has(chatId)) continue;
|
|
373
|
+
if (isWarmChatInFlight(accountId, chatId)) continue;
|
|
374
|
+
pool.push({ chatId, headers, accountId, timestamp: Date.now() });
|
|
375
|
+
existingIds.add(chatId);
|
|
376
|
+
reused++;
|
|
377
|
+
}
|
|
378
|
+
if (reused > 0) {
|
|
379
|
+
console.log(`[WarmPool] Reused ${reused} existing unused chats for ${accountId}`);
|
|
380
|
+
}
|
|
381
|
+
} catch (err: any) {
|
|
382
|
+
console.warn(`[WarmPool] Failed to fetch unused chats for ${accountId}:`, err.message);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const stillNeed = Math.max(0, need - reused);
|
|
386
|
+
for (let i = 0; i < stillNeed; i++) {
|
|
221
387
|
if (i > 0) {
|
|
222
388
|
await sleep(800 + Math.floor(Math.random() * 2200));
|
|
223
389
|
}
|
|
@@ -264,7 +430,9 @@ export async function getWarmedChat(accountId?: string) {
|
|
|
264
430
|
await refillPromises.get(key);
|
|
265
431
|
}
|
|
266
432
|
if (pool.length === 0) throw new Error(`Warm pool empty after retry for ${key}`);
|
|
267
|
-
|
|
433
|
+
const entry = pool.shift()!;
|
|
434
|
+
markWarmChatInFlight(key, entry.chatId);
|
|
435
|
+
return entry;
|
|
268
436
|
}
|
|
269
437
|
|
|
270
438
|
export async function warmAllPools(accountIds: string[]) {
|
|
@@ -355,7 +523,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
355
523
|
'timezone': CACHED_TIMEZONE,
|
|
356
524
|
},
|
|
357
525
|
body: JSON.stringify(payload),
|
|
358
|
-
timeoutMs:
|
|
526
|
+
timeoutMs: config.timeouts.http,
|
|
359
527
|
});
|
|
360
528
|
if (result.status && result.status < 400) {
|
|
361
529
|
console.log(`[Qwen] Native tools disabled successfully for ${cacheKey}.`);
|
|
@@ -370,7 +538,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
370
538
|
}
|
|
371
539
|
|
|
372
540
|
const controller = new AbortController();
|
|
373
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
541
|
+
const timeoutId = setTimeout(() => controller.abort(), config.timeouts.http);
|
|
374
542
|
const response = await fetch('https://chat.qwen.ai/api/v2/users/user/settings/update', {
|
|
375
543
|
method: 'POST',
|
|
376
544
|
headers: {
|
|
@@ -422,7 +590,7 @@ export async function fetchQwenModels(accountId?: string): Promise<any[]> {
|
|
|
422
590
|
'timezone': CACHED_TIMEZONE,
|
|
423
591
|
'source': 'web',
|
|
424
592
|
},
|
|
425
|
-
timeoutMs:
|
|
593
|
+
timeoutMs: config.timeouts.http,
|
|
426
594
|
});
|
|
427
595
|
if (result.status && result.status < 400) {
|
|
428
596
|
return processModelsJson(JSON.parse(result.body));
|
|
@@ -507,6 +675,34 @@ export async function createQwenStream(
|
|
|
507
675
|
): Promise<{ stream: ReadableStream, headers: Record<string, string>, uiSessionId: string, controller: AbortController, accountId: string }> {
|
|
508
676
|
let chatId: string;
|
|
509
677
|
let chatHeaders: Record<string, string>;
|
|
678
|
+
let leasedChat: WarmPoolEntry | undefined;
|
|
679
|
+
let leasedChatReleased = false;
|
|
680
|
+
|
|
681
|
+
const releaseLeasedChat = () => {
|
|
682
|
+
if (leasedChatReleased || !leasedChat) return;
|
|
683
|
+
leasedChatReleased = true;
|
|
684
|
+
releaseWarmChat(leasedChat.accountId, leasedChat.chatId);
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const wrapLeasedStream = (
|
|
688
|
+
stream: ReadableStream<Uint8Array>,
|
|
689
|
+
controller: AbortController,
|
|
690
|
+
timeoutMs: number,
|
|
691
|
+
label: string,
|
|
692
|
+
onTimeout?: () => void,
|
|
693
|
+
) => {
|
|
694
|
+
return addIdleTimeoutToStream(
|
|
695
|
+
stream,
|
|
696
|
+
controller,
|
|
697
|
+
timeoutMs,
|
|
698
|
+
label,
|
|
699
|
+
onTimeout,
|
|
700
|
+
() => {
|
|
701
|
+
onTimeout?.();
|
|
702
|
+
releaseLeasedChat();
|
|
703
|
+
},
|
|
704
|
+
);
|
|
705
|
+
};
|
|
510
706
|
|
|
511
707
|
if (accountId === 'guest') {
|
|
512
708
|
chatHeaders = await getGuestHeaders();
|
|
@@ -526,7 +722,7 @@ export async function createQwenStream(
|
|
|
526
722
|
method: 'POST',
|
|
527
723
|
headers: { 'accept': 'application/json, text/plain, */*', 'content-type': 'application/json', 'x-request-id': crypto.randomUUID(), 'timezone': CACHED_TIMEZONE },
|
|
528
724
|
body: guestBody,
|
|
529
|
-
timeoutMs:
|
|
725
|
+
timeoutMs: config.timeouts.http,
|
|
530
726
|
});
|
|
531
727
|
if (!result.status || result.status >= 400) throw new Error(`Failed to create guest chat: ${result.status}`);
|
|
532
728
|
const json = JSON.parse(result.body);
|
|
@@ -538,7 +734,7 @@ export async function createQwenStream(
|
|
|
538
734
|
method: 'POST',
|
|
539
735
|
headers: { 'accept': 'application/json, text/plain, */*', 'content-type': 'application/json', cookie: chatHeaders['cookie'], origin: 'https://chat.qwen.ai', referer: 'https://chat.qwen.ai/c/guest', 'user-agent': chatHeaders['user-agent'], 'x-request-id': crypto.randomUUID(), 'bx-v': chatHeaders['bx-v'], 'bx-ua': chatHeaders['bx-ua'], 'bx-umidtoken': chatHeaders['bx-umidtoken'], ...getClientHintsHeaders() },
|
|
540
736
|
body: guestBody,
|
|
541
|
-
signal: AbortSignal.timeout(
|
|
737
|
+
signal: AbortSignal.timeout(config.timeouts.http),
|
|
542
738
|
});
|
|
543
739
|
if (!response.ok) throw new Error(`Failed to create guest chat: ${response.status}`);
|
|
544
740
|
const json = await response.json();
|
|
@@ -550,7 +746,7 @@ export async function createQwenStream(
|
|
|
550
746
|
method: 'POST',
|
|
551
747
|
headers: { 'accept': 'application/json, text/plain, */*', 'content-type': 'application/json', cookie: chatHeaders['cookie'], origin: 'https://chat.qwen.ai', referer: 'https://chat.qwen.ai/c/guest', 'user-agent': chatHeaders['user-agent'], 'x-request-id': crypto.randomUUID(), 'bx-v': chatHeaders['bx-v'], 'bx-ua': chatHeaders['bx-ua'], 'bx-umidtoken': chatHeaders['bx-umidtoken'], ...getClientHintsHeaders() },
|
|
552
748
|
body: guestBody,
|
|
553
|
-
signal: AbortSignal.timeout(
|
|
749
|
+
signal: AbortSignal.timeout(config.timeouts.http),
|
|
554
750
|
});
|
|
555
751
|
if (!response.ok) throw new Error(`Failed to create guest chat: ${response.status}`);
|
|
556
752
|
const json = await response.json();
|
|
@@ -558,9 +754,8 @@ export async function createQwenStream(
|
|
|
558
754
|
if (!chatId) throw new Error(`Unexpected guest chat response: ${JSON.stringify(json).slice(0, 200)}`);
|
|
559
755
|
}
|
|
560
756
|
} else {
|
|
561
|
-
let chatEntry: WarmPoolEntry;
|
|
562
757
|
try {
|
|
563
|
-
|
|
758
|
+
leasedChat = await getWarmedChat(accountId);
|
|
564
759
|
} catch (err: any) {
|
|
565
760
|
if (err.message?.includes('chat is in progress') || err.message?.includes('The chat is in progress')) {
|
|
566
761
|
const retryAfterMs = 2000 + Math.floor(Math.random() * 2000);
|
|
@@ -568,8 +763,8 @@ export async function createQwenStream(
|
|
|
568
763
|
}
|
|
569
764
|
throw err;
|
|
570
765
|
}
|
|
571
|
-
chatId =
|
|
572
|
-
chatHeaders =
|
|
766
|
+
chatId = leasedChat.chatId;
|
|
767
|
+
chatHeaders = leasedChat.headers;
|
|
573
768
|
}
|
|
574
769
|
|
|
575
770
|
const actualParentId: string | null = null;
|
|
@@ -608,7 +803,8 @@ export async function createQwenStream(
|
|
|
608
803
|
}
|
|
609
804
|
}
|
|
610
805
|
|
|
611
|
-
|
|
806
|
+
try {
|
|
807
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
612
808
|
const fid = crypto.randomUUID();
|
|
613
809
|
const model = modelId.replace('-no-thinking', '');
|
|
614
810
|
|
|
@@ -682,7 +878,7 @@ export async function createQwenStream(
|
|
|
682
878
|
|
|
683
879
|
if (browserResult.contentType.includes('text/event-stream') && browserResult.status < 400) {
|
|
684
880
|
const controller = new AbortController();
|
|
685
|
-
return { stream: browserResult.stream, headers: chatHeaders, uiSessionId: chatId, controller, accountId: accountId || 'guest' };
|
|
881
|
+
return { stream: wrapLeasedStream(browserResult.stream, controller, timeoutMs, `Qwen browser stream ${chatId}`, browserResult.abort), headers: chatHeaders, uiSessionId: chatId, controller, accountId: accountId || 'guest' };
|
|
686
882
|
}
|
|
687
883
|
|
|
688
884
|
if (browserResult.body) {
|
|
@@ -700,7 +896,7 @@ export async function createQwenStream(
|
|
|
700
896
|
});
|
|
701
897
|
if (retryResult.contentType.includes('text/event-stream') && retryResult.status < 400) {
|
|
702
898
|
const controller = new AbortController();
|
|
703
|
-
return { stream: retryResult.stream, headers: freshHeaders, uiSessionId: chatId, controller, accountId: accountId || 'guest' };
|
|
899
|
+
return { stream: wrapLeasedStream(retryResult.stream, controller, timeoutMs, `Qwen browser stream ${chatId}`, retryResult.abort), headers: freshHeaders, uiSessionId: chatId, controller, accountId: accountId || 'guest' };
|
|
704
900
|
}
|
|
705
901
|
if (retryResult.body && (retryResult.body.includes('FAIL_SYS_USER_VALIDATE') || retryResult.body.includes('_____tmd_____'))) {
|
|
706
902
|
throw new QwenUpstreamError('Qwen TMD challenge persists after header refresh.', 'FAIL_SYS_USER_VALIDATE', 403);
|
|
@@ -788,7 +984,7 @@ export async function createQwenStream(
|
|
|
788
984
|
|
|
789
985
|
const retryContentType = retryResponse.headers.get('content-type') || '';
|
|
790
986
|
if (retryResponse.ok && retryContentType.includes('text/event-stream') && retryResponse.body) {
|
|
791
|
-
return { stream: retryResponse.body, headers: freshHeaders, uiSessionId: chatId, controller: retryController, accountId: accountId || 'guest' };
|
|
987
|
+
return { stream: wrapLeasedStream(retryResponse.body, retryController, timeoutMs, `Qwen stream ${chatId}`), headers: freshHeaders, uiSessionId: chatId, controller: retryController, accountId: accountId || 'guest' };
|
|
792
988
|
}
|
|
793
989
|
|
|
794
990
|
const retryPeek = await retryResponse.clone().text().catch(() => '');
|
|
@@ -797,7 +993,7 @@ export async function createQwenStream(
|
|
|
797
993
|
}
|
|
798
994
|
|
|
799
995
|
if (retryResponse.ok && retryResponse.body) {
|
|
800
|
-
return { stream: retryResponse.body, headers: freshHeaders, uiSessionId: chatId, controller: retryController, accountId: accountId || 'guest' };
|
|
996
|
+
return { stream: wrapLeasedStream(retryResponse.body, retryController, timeoutMs, `Qwen stream ${chatId}`), headers: freshHeaders, uiSessionId: chatId, controller: retryController, accountId: accountId || 'guest' };
|
|
801
997
|
}
|
|
802
998
|
} catch (retryErr) {
|
|
803
999
|
if (retryErr instanceof QwenUpstreamError) throw retryErr;
|
|
@@ -820,7 +1016,11 @@ export async function createQwenStream(
|
|
|
820
1016
|
throw new Error(`Failed to fetch from Qwen: ${response.status} ${response.statusText} - ${errText}`);
|
|
821
1017
|
}
|
|
822
1018
|
|
|
823
|
-
return { stream: response.body, headers: chatHeaders, uiSessionId: chatId, controller, accountId: accountId || 'guest' };
|
|
1019
|
+
return { stream: wrapLeasedStream(response.body, controller, timeoutMs, `Qwen stream ${chatId}`), headers: chatHeaders, uiSessionId: chatId, controller, accountId: accountId || 'guest' };
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
releaseLeasedChat();
|
|
1022
|
+
throw err;
|
|
1023
|
+
}
|
|
824
1024
|
}
|
|
825
1025
|
|
|
826
1026
|
function handleErrorBody(peekText: string, status: number): never {
|
|
@@ -135,6 +135,27 @@ test('truncateMessages: handles empty messages array', () => {
|
|
|
135
135
|
assert.strictEqual(result.length, 0);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
test('truncateMessages: preserves earlier tool memory when truncating history', () => {
|
|
139
|
+
const messages = [
|
|
140
|
+
{
|
|
141
|
+
role: 'assistant',
|
|
142
|
+
content: 'I will inspect the file.',
|
|
143
|
+
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/a.txt' }) } }],
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
role: 'tool',
|
|
147
|
+
name: 'read_file',
|
|
148
|
+
content: 'old tool result that should be summarized',
|
|
149
|
+
},
|
|
150
|
+
{ role: 'user', content: 'x'.repeat(5000) },
|
|
151
|
+
];
|
|
152
|
+
const result = truncateMessages(messages, 1000);
|
|
153
|
+
assert.ok(result.some(m => m.content.includes('[Earlier tool memory]')));
|
|
154
|
+
assert.ok(result.some(m => m.content.includes('read_file')));
|
|
155
|
+
assert.ok(result.some(m => m.content.includes('/tmp/a.txt')));
|
|
156
|
+
assert.ok(result.some(m => m.content.includes('old tool result')));
|
|
157
|
+
});
|
|
158
|
+
|
|
138
159
|
test('truncateMessages: handles empty messages with system prompt fallback', () => {
|
|
139
160
|
const result = truncateMessages([], 5, 'fallback');
|
|
140
161
|
assert.strictEqual(result.length, 1);
|
|
@@ -30,6 +30,60 @@ function truncateSemantically(content: string, maxChars: number): string {
|
|
|
30
30
|
return truncated + '... [Truncated]';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const TOOL_MEMORY_MAX_ITEMS = 24;
|
|
34
|
+
const TOOL_MEMORY_ITEM_MAX_CHARS = 180;
|
|
35
|
+
|
|
36
|
+
function summarizeContent(content: string, maxChars = TOOL_MEMORY_ITEM_MAX_CHARS): string {
|
|
37
|
+
const compact = content.replace(/\s+/g, ' ').trim();
|
|
38
|
+
if (compact.length <= maxChars) return compact;
|
|
39
|
+
return `${compact.slice(0, maxChars)}... [truncated]`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stringifyToolArgs(args: unknown): string {
|
|
43
|
+
try {
|
|
44
|
+
return summarizeContent(JSON.stringify(args), 220);
|
|
45
|
+
} catch {
|
|
46
|
+
return summarizeContent(String(args), 220);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildToolMemory(messages: Array<{ role: string; content: string | null | any[] | Record<string, unknown>; tool_calls?: any[]; name?: string; tool_call_id?: string }>): string {
|
|
51
|
+
const lines: string[] = [];
|
|
52
|
+
|
|
53
|
+
for (const msg of messages) {
|
|
54
|
+
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) {
|
|
55
|
+
for (const call of msg.tool_calls) {
|
|
56
|
+
const name = call?.function?.name || call?.name || 'unknown_tool';
|
|
57
|
+
let args: unknown = {};
|
|
58
|
+
if (typeof call?.function?.arguments === 'string') {
|
|
59
|
+
try {
|
|
60
|
+
args = JSON.parse(call.function.arguments);
|
|
61
|
+
} catch {
|
|
62
|
+
args = call.function.arguments;
|
|
63
|
+
}
|
|
64
|
+
} else if (call?.function?.arguments !== undefined) {
|
|
65
|
+
args = call.function.arguments;
|
|
66
|
+
}
|
|
67
|
+
lines.push(`- call ${call.id || 'unknown'}: ${name}(${stringifyToolArgs(args)})`);
|
|
68
|
+
if (lines.length >= TOOL_MEMORY_MAX_ITEMS) return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (msg.role === 'tool' || msg.role === 'function') {
|
|
73
|
+
const contentStr = Array.isArray(msg.content)
|
|
74
|
+
? msg.content.map((c: any) => c.text || JSON.stringify(c)).join('\n')
|
|
75
|
+
: typeof msg.content === 'object' && msg.content !== null
|
|
76
|
+
? JSON.stringify(msg.content)
|
|
77
|
+
: msg.content || '';
|
|
78
|
+
const toolName = msg.name || msg.tool_call_id || 'tool';
|
|
79
|
+
lines.push(`- ${toolName} response: ${summarizeContent(contentStr)}`);
|
|
80
|
+
if (lines.length >= TOOL_MEMORY_MAX_ITEMS) return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
33
87
|
export function truncateMessages(
|
|
34
88
|
messages: Array<{ role: string; content: string | null | any[] | Record<string, unknown> }>,
|
|
35
89
|
maxContextLength: number,
|
|
@@ -46,6 +100,7 @@ export function truncateMessages(
|
|
|
46
100
|
|
|
47
101
|
const result: Array<{ role: string; content: string }> = [];
|
|
48
102
|
let usedTokens = 0;
|
|
103
|
+
let droppedToolMemory = '';
|
|
49
104
|
|
|
50
105
|
const normalizedMessages = messages.map(msg => {
|
|
51
106
|
let contentStr = '';
|
|
@@ -56,7 +111,7 @@ export function truncateMessages(
|
|
|
56
111
|
} else {
|
|
57
112
|
contentStr = msg.content || '';
|
|
58
113
|
}
|
|
59
|
-
return { role: msg.role, content: contentStr };
|
|
114
|
+
return { role: msg.role, content: contentStr, tool_calls: (msg as any).tool_calls, name: (msg as any).name, tool_call_id: (msg as any).tool_call_id };
|
|
60
115
|
});
|
|
61
116
|
|
|
62
117
|
for (let i = normalizedMessages.length - 1; i >= 0; i--) {
|
|
@@ -73,6 +128,7 @@ export function truncateMessages(
|
|
|
73
128
|
const truncatedContent = truncateSemantically(msg.content, maxChars);
|
|
74
129
|
result.push({ role: msg.role, content: `[Truncated] ${truncatedContent}` });
|
|
75
130
|
}
|
|
131
|
+
droppedToolMemory = buildToolMemory(normalizedMessages.slice(0, i));
|
|
76
132
|
break;
|
|
77
133
|
}
|
|
78
134
|
}
|
|
@@ -84,6 +140,7 @@ export function truncateMessages(
|
|
|
84
140
|
result.push({ role: lastMsg.role, content: `[Truncated] ${truncatedContent}` });
|
|
85
141
|
}
|
|
86
142
|
|
|
87
|
-
result.reverse();
|
|
88
|
-
return
|
|
143
|
+
const truncated = result.reverse();
|
|
144
|
+
if (!droppedToolMemory) return truncated;
|
|
145
|
+
return [{ role: 'user', content: `[Earlier tool memory]\n${droppedToolMemory}` }, ...truncated];
|
|
89
146
|
}
|