@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pedrofariasx/qwenproxy",
3
- "version": "1.6.3",
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.0",
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
  },
@@ -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('30000'),
12
- PAGE_TIMEOUT: z.string().default('15000'),
13
- HTTP_TIMEOUT: z.string().default('10000'),
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),
@@ -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
- // Reforço de instrução de tool call para contextos longos (mitiga "Lost in the Middle")
264
- if (hasTools && estimatedTokens > 15000) {
265
- 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.]';
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
- fastWriteContent(text);
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
- writeEvent({
757
- id: completionId,
758
- object: 'chat.completion.chunk',
759
- created: createdTimestamp,
760
- model: body.model,
761
- choices: [makeChoice({ content: flushResult.text })]
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: 10000 });
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: 5000 });
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: 10000 });
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')), 30000);
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: 10000 });
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: 30000 }).catch(() => {
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
- }, 60000);
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: 15000 });
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: 15000 });
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
- const metaPromise = new Promise<{ status: number; statusText: string; contentType: string; headers: Record<string, string> }>((resolve) => {
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
- metaResolve({ status: 0, statusText: 'Timeout', contentType: '', headers: {} });
1125
- }, options.timeoutMs || 130000);
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
- const bodyPromise = new Promise<string>((resolve) => { bodyResolve = resolve; });
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 || 130000);
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',
@@ -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: 30000,
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(30000),
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
- for (let i = 0; i < need; i++) {
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
- return pool.shift()!;
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: 30000,
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(), 30000);
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: 30000,
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: 30000,
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(30000),
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(30000),
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
- chatEntry = await getWarmedChat(accountId);
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 = chatEntry.chatId;
572
- chatHeaders = chatEntry.headers;
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
- const timestamp = Math.floor(Date.now() / 1000);
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 result;
143
+ const truncated = result.reverse();
144
+ if (!droppedToolMemory) return truncated;
145
+ return [{ role: 'user', content: `[Earlier tool memory]\n${droppedToolMemory}` }, ...truncated];
89
146
  }