@pedrofariasx/qwenproxy 1.2.3 → 1.3.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.2.3",
3
+ "version": "1.3.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": {
package/src/api/models.ts CHANGED
@@ -4,6 +4,7 @@ import { getBasicHeaders } from '../services/playwright.js'
4
4
  import { loadAccounts } from '../core/accounts.js'
5
5
  import { getAccountCooldownInfo } from '../core/account-manager.js'
6
6
  import { cache } from '../cache/memory-cache.js'
7
+ import { syncModelContextWindows } from '../core/model-registry.js'
7
8
 
8
9
  const app = new Hono()
9
10
 
@@ -80,7 +81,7 @@ app.get('/v1/models', async (c) => {
80
81
  ],
81
82
  }
82
83
 
83
- // Cache the formatted models list for 5 minutes (300 seconds)
84
+ syncModelContextWindows(formatted.data)
84
85
  await cache.set(cacheKey, formatted, 300)
85
86
 
86
87
  return c.json(formatted)
@@ -163,6 +164,7 @@ app.get('/v1/models/:model', async (c) => {
163
164
  ],
164
165
  }
165
166
 
167
+ syncModelContextWindows(formatted.data)
166
168
  await cache.set(cacheKey, formatted, 300)
167
169
  models = formatted.data
168
170
  }
@@ -1,16 +1,58 @@
1
1
  const modelContextWindows: Record<string, number> = {
2
- 'qwen-max': 32768,
3
- 'qwen-max-latest': 32768,
4
- 'qwen-plus': 131072,
5
- 'qwen-plus-latest': 131072,
6
- 'qwen-turbo': 131072,
7
- 'qwen-turbo-latest': 131072,
8
- 'qwen-long': 1000000,
9
- 'qwen-coder': 131072,
10
- 'qwen-coder-plus': 131072,
2
+ 'qwen3.7-plus': 1000000,
3
+ 'qwen3.7-max': 1000000,
4
+ 'qwen3.6-plus': 1000000,
5
+ 'qwen3.6-plus-preview': 1000000,
6
+ 'qwen3.6-max-preview': 262144,
7
+ 'qwen3.6-27b': 262144,
8
+ 'qwen3.6-35b-a3b': 262144,
9
+ 'qwen3.5-plus': 1000000,
10
+ 'qwen3.5-flash': 1000000,
11
+ 'qwen3.5-omni-plus': 262144,
12
+ 'qwen3.5-omni-flash': 262144,
13
+ 'qwen3.5-max-2026-03-08': 262144,
14
+ 'qwen3.5-397b-a17b': 262144,
15
+ 'qwen3.5-122b-a10b': 262144,
16
+ 'qwen3.5-27b': 262144,
17
+ 'qwen3.5-35b-a3b': 262144,
18
+ 'qwen3-max-2026-01-23': 262144,
19
+ 'qwen3-coder-plus': 1048576,
20
+ 'qwen3-vl-plus': 262144,
21
+ 'qwen3-omni-flash-2025-12-01': 65536,
22
+ 'qwen-plus-2025-07-28': 131072,
23
+ 'qwen-latest-series-invite-beta-v24': 262144,
24
+ 'qwen-latest-series-invite-beta-v16': 1000000,
25
+ }
26
+
27
+ const modelTokenDivisors: Record<string, number> = {
28
+ 'qwen3.7-max': 2.2,
29
+ 'qwen3.6-max-preview': 2.2,
30
+ 'qwen3.5-max-2026-03-08': 2.2,
31
+ 'qwen3-max-2026-01-23': 2.2,
32
+ 'qwen-latest-series-invite-beta-v24': 2.2,
33
+ 'qwen3.7-plus': 2.0,
34
+ 'qwen3.6-plus': 2.0,
35
+ 'qwen3.6-plus-preview': 2.0,
36
+ 'qwen3.5-plus': 2.0,
37
+ 'qwen-plus-2025-07-28': 2.0,
38
+ 'qwen-latest-series-invite-beta-v16': 2.0,
39
+ 'qwen3.5-flash': 1.8,
40
+ 'qwen3.5-omni-plus': 1.8,
41
+ 'qwen3.5-omni-flash': 1.7,
42
+ 'qwen3-omni-flash-2025-12-01': 1.7,
43
+ 'qwen3.5-397b-a17b': 1.9,
44
+ 'qwen3.5-122b-a10b': 1.9,
45
+ 'qwen3.6-35b-a3b': 1.9,
46
+ 'qwen3.5-35b-a3b': 1.9,
47
+ 'qwen3.6-27b': 1.9,
48
+ 'qwen3.5-27b': 1.9,
49
+ 'qwen3-coder-plus': 2.3,
50
+ 'qwen3-vl-plus': 2.1,
11
51
  }
12
52
 
13
53
  const defaultContextWindow = 131072
54
+ const defaultTokenDivisor = 2.0
55
+ export const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024
14
56
 
15
57
  export function setModelContextWindow(modelId: string, contextWindow: number): void {
16
58
  modelContextWindows[modelId] = contextWindow
@@ -21,6 +63,11 @@ export function getModelContextWindow(modelId: string): number {
21
63
  return modelContextWindows[baseId] ?? defaultContextWindow
22
64
  }
23
65
 
66
+ export function getModelTokenDivisor(modelId: string): number {
67
+ const baseId = modelId.replace('-no-thinking', '')
68
+ return modelTokenDivisors[baseId] ?? defaultTokenDivisor
69
+ }
70
+
24
71
  export function syncModelContextWindows(models: Array<{ id: string; context_window?: number }>): void {
25
72
  for (const m of models) {
26
73
  if (m.context_window) {
@@ -52,14 +52,6 @@ export function getIncrementalDelta(oldStr: string, newStr: string, prevLength:
52
52
  const actualSuffix = newStr.slice(prevLength - checkLen, prevLength);
53
53
 
54
54
  if (expectedSuffix === actualSuffix) {
55
- if (delta.length <= 4 && oldStr.length > 2000) {
56
- return {
57
- delta: newStr,
58
- matchedContent: oldStr + newStr,
59
- contentLength: newStr.length,
60
- contentSuffix: newStr.slice(-64)
61
- };
62
- }
63
55
  return {
64
56
  delta,
65
57
  matchedContent: newStr,
@@ -72,14 +64,6 @@ export function getIncrementalDelta(oldStr: string, newStr: string, prevLength:
72
64
  // Fallback: startsWith check for edge cases
73
65
  if (newStr.startsWith(oldStr)) {
74
66
  const delta = newStr.slice(oldStr.length);
75
- if (delta.length <= 4 && oldStr.length > 2000) {
76
- return {
77
- delta: newStr,
78
- matchedContent: oldStr + newStr,
79
- contentLength: newStr.length,
80
- contentSuffix: newStr.slice(-64)
81
- };
82
- }
83
67
  return {
84
68
  delta,
85
69
  matchedContent: newStr,
@@ -263,12 +247,12 @@ export async function chatCompletions(c: Context) {
263
247
 
264
248
  const modelId = body.model.replace('-no-thinking', '');
265
249
  const modelContextWindow = getModelContextWindow(modelId)
266
- const estimatedTokens = estimateTokenCount(systemPrompt + prompt);
250
+ const estimatedTokens = estimateTokenCount(systemPrompt + prompt, modelId);
267
251
  const hasTools = Array.isArray(bodyAny.tools) && bodyAny.tools.length > 0;
268
252
 
269
253
  let finalPrompt: string;
270
254
  if (estimatedTokens > modelContextWindow - 1000) {
271
- const truncated = truncateMessages(messages, modelContextWindow, systemPrompt);
255
+ const truncated = truncateMessages(messages, modelContextWindow, systemPrompt, modelId);
272
256
  const truncatedBody = truncated.map(m => `${m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : m.role}: ${m.content}`).join('\n\n');
273
257
  finalPrompt = systemPrompt ? `${systemPrompt}\n\n${truncatedBody}` : truncatedBody;
274
258
  } else {
@@ -672,7 +656,9 @@ export async function chatCompletions(c: Context) {
672
656
  }
673
657
  }
674
658
  } catch (e) {
675
- // parse error, ignore partial chunk
659
+ if (dataStr.length > 10) {
660
+ console.warn(`[Chat] SSE parse error for chunk (${dataStr.length} chars):`, (e as Error).message);
661
+ }
676
662
  }
677
663
  }
678
664
 
@@ -302,7 +302,9 @@ export async function getQwenHeaders(forceNew = false, accountId?: string): Prom
302
302
  if (age < HEADERS_TTL) {
303
303
  if (age > HEADERS_TTL * REFRESH_THRESHOLD && !cache.refreshInProgress) {
304
304
  cache.refreshInProgress = true;
305
- getQwenHeaders(true, accountId).finally(() => {
305
+ getQwenHeaders(true, accountId).catch((err) => {
306
+ console.warn(`[Playwright] Background header refresh failed for ${cacheKey}:`, (err as Error).message);
307
+ }).finally(() => {
306
308
  cache.refreshInProgress = false;
307
309
  });
308
310
  }
@@ -1,7 +1,10 @@
1
1
  import { getQwenHeaders, getBasicHeaders } from './playwright.js';
2
+ import { MAX_PAYLOAD_SIZE } from '../core/model-registry.js';
2
3
  import crypto from 'crypto';
3
4
 
4
5
  const CACHED_TIMEZONE = new Date().toString().split(' (')[0];
6
+ const BASE_TIMEOUT_MS = 120000;
7
+ const TIMEOUT_PER_MB = 30000;
5
8
 
6
9
  export class RetryableQwenStreamError extends Error {
7
10
  readonly retryAfterMs: number;
@@ -162,7 +165,15 @@ export async function getWarmedChat(accountId?: string) {
162
165
  }
163
166
  await refillPromises.get(key);
164
167
  }
165
- if (pool.length === 0) throw new Error(`Warm pool empty for ${key}`);
168
+ if (pool.length === 0) {
169
+ // Retry once with short backoff if pool is still empty after first refill attempt
170
+ await new Promise(r => setTimeout(r, 1000));
171
+ if (!refillPromises.has(key)) {
172
+ refillPromises.set(key, refillPoolForAccount(key).finally(() => refillPromises.delete(key)));
173
+ }
174
+ await refillPromises.get(key);
175
+ }
176
+ if (pool.length === 0) throw new Error(`Warm pool empty after retry for ${key}`);
166
177
  return pool.shift()!;
167
178
  }
168
179
 
@@ -395,6 +406,7 @@ export async function createQwenStream(
395
406
  }
396
407
  } catch (err: any) {
397
408
  console.error('[Qwen] Failed to process multimodal uploads:', err.message);
409
+ throw new Error(`Multimodal upload failed: ${err.message}`);
398
410
  }
399
411
  }
400
412
 
@@ -443,9 +455,17 @@ export async function createQwenStream(
443
455
  timestamp: timestamp + 1
444
456
  };
445
457
 
458
+ const payloadJson = JSON.stringify(payload);
459
+ const payloadSize = Buffer.byteLength(payloadJson);
460
+ if (payloadSize > MAX_PAYLOAD_SIZE) {
461
+ throw new Error(`Payload too large: ${payloadSize} bytes exceeds limit of ${MAX_PAYLOAD_SIZE} bytes`);
462
+ }
463
+ const payloadMB = payloadSize / (1024 * 1024);
464
+ const timeoutMs = BASE_TIMEOUT_MS + Math.ceil(payloadMB * TIMEOUT_PER_MB);
465
+
446
466
  const url = `https://chat.qwen.ai/api/v2/chat/completions?chat_id=${chatId}`;
447
467
  const controller = new AbortController();
448
- const timeoutId = setTimeout(() => controller.abort(), 120000);
468
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
449
469
  const response = await fetch(url, {
450
470
  method: 'POST',
451
471
  headers: {
@@ -464,7 +484,7 @@ export async function createQwenStream(
464
484
  'x-request-id': crypto.randomUUID(),
465
485
  'bx-v': chatHeaders['bx-v'],
466
486
  },
467
- body: JSON.stringify(payload),
487
+ body: payloadJson,
468
488
  signal: controller.signal
469
489
  });
470
490
  clearTimeout(timeoutId);
@@ -108,3 +108,20 @@ test('robustParseJSON: handles boolean and null values', () => {
108
108
  assert.strictEqual(result.deleted, false);
109
109
  assert.strictEqual(result.data, null);
110
110
  });
111
+
112
+ test('robustParseJSON: handles unquoted string value after colon', () => {
113
+ const result = robustParseJSON('{"name": "bash", "arguments": {"command":export CI=true GIT_PAGER=cat npm run build, "description": "Build"}}');
114
+ assert.ok(result);
115
+ assert.strictEqual(result.name, 'bash');
116
+ assert.ok(typeof result.arguments.command === 'string');
117
+ assert.ok(result.arguments.command.includes('export CI=true'));
118
+ assert.strictEqual(result.arguments.description, 'Build');
119
+ });
120
+
121
+ test('robustParseJSON: handles unquoted string value with special chars', () => {
122
+ const result = robustParseJSON('{"name": "bash", "arguments": {"command":git add -A && git commit -m "fix"}}');
123
+ assert.ok(result);
124
+ assert.strictEqual(result.name, 'bash');
125
+ assert.ok(typeof result.arguments.command === 'string');
126
+ assert.ok(result.arguments.command.includes('git add'));
127
+ });
@@ -2,10 +2,12 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { StreamingToolParser } from '../tools/parser.js';
4
4
 
5
+ const TC_OPEN = '<tool_' + 'call>';
6
+ const TC_CLOSE = '</tool_' + 'call>';
7
+
5
8
  test('StreamingToolParser: basic tool call', () => {
6
9
  const parser = new StreamingToolParser();
7
-
8
- const result = parser.feed('Hello! <tool_call>{"name": "t1", "arguments": {"a": 1}}</tool_call>');
10
+ const result = parser.feed(`Hello! ${TC_OPEN}{"name": "t1", "arguments": {"a": 1}}${TC_CLOSE}`);
9
11
  assert.strictEqual(result.text, 'Hello! ');
10
12
  assert.strictEqual(result.toolCalls.length, 1);
11
13
  assert.strictEqual(result.toolCalls[0].name, 't1');
@@ -13,8 +15,7 @@ test('StreamingToolParser: basic tool call', () => {
13
15
 
14
16
  test('StreamingToolParser: multiple tool calls', () => {
15
17
  const parser = new StreamingToolParser();
16
-
17
- const result = parser.feed('<tool_call>{"name": "t2", "arguments": {}}</tool_call><tool_call>{"name": "t3", "arguments": {}}</tool_call>');
18
+ const result = parser.feed(`${TC_OPEN}{"name": "t2", "arguments": {}}${TC_CLOSE}${TC_OPEN}{"name": "t3", "arguments": {}}${TC_CLOSE}`);
18
19
  assert.strictEqual(result.text, '');
19
20
  assert.strictEqual(result.toolCalls.length, 2);
20
21
  assert.strictEqual(result.toolCalls[0].name, 't2');
@@ -23,11 +24,9 @@ test('StreamingToolParser: multiple tool calls', () => {
23
24
 
24
25
  test('StreamingToolParser: fragmented tool call', () => {
25
26
  const parser = new StreamingToolParser();
26
-
27
27
  assert.strictEqual(parser.feed('Text <tool_').text, 'Text ');
28
28
  assert.strictEqual(parser.feed('call>{"name": ').text, '');
29
- const final = parser.feed('"frag", "arguments": {}}</tool_call> trailing');
30
-
29
+ const final = parser.feed(`"frag", "arguments": {}}${TC_CLOSE} trailing`);
31
30
  assert.strictEqual(final.toolCalls.length, 1);
32
31
  assert.strictEqual(final.toolCalls[0].name, 'frag');
33
32
  assert.strictEqual(final.text, ' trailing');
@@ -35,26 +34,24 @@ test('StreamingToolParser: fragmented tool call', () => {
35
34
 
36
35
  test('StreamingToolParser: flush partial content', () => {
37
36
  const parser = new StreamingToolParser();
38
-
39
37
  parser.feed('Unfinished tag <tool_');
40
38
  assert.strictEqual(parser.flush().text, '<tool_');
41
39
 
42
40
  const parser2 = new StreamingToolParser();
43
- parser2.feed('Broken tool <tool_call>{"name": "healable"');
41
+ parser2.feed(`${TC_OPEN}{"name": "healable"`);
44
42
  const flushed = parser2.flush();
45
43
  assert.strictEqual(flushed.toolCalls.length, 1);
46
44
  assert.strictEqual(flushed.toolCalls[0].name, 'healable');
47
-
45
+
48
46
  const parser3 = new StreamingToolParser();
49
- parser3.feed('Invalid <tool_call>NOT_JSON');
47
+ parser3.feed(`Invalid ${TC_OPEN}NOT_JSON`);
50
48
  const flushed2 = parser3.flush();
51
- assert.strictEqual(flushed2.text, '<tool_call>NOT_JSON</tool_call>');
49
+ assert.strictEqual(flushed2.text, `${TC_OPEN}NOT_JSON${TC_CLOSE}`);
52
50
  });
53
51
 
54
52
  test('StreamingToolParser: robust parsing of malformed JSON', () => {
55
53
  const parser = new StreamingToolParser();
56
-
57
- const res = parser.feed('<tool_call>{"name": "broken", "arguments": {"a": 1}</tool_call>');
54
+ const res = parser.feed(`${TC_OPEN}{"name": "broken", "arguments": {"a": 1}${TC_CLOSE}`);
58
55
  assert.strictEqual(res.toolCalls.length, 1);
59
56
  assert.strictEqual(res.toolCalls[0].name, 'broken');
60
57
  assert.deepStrictEqual(res.toolCalls[0].arguments, { a: 1 });
@@ -62,25 +59,22 @@ test('StreamingToolParser: robust parsing of malformed JSON', () => {
62
59
 
63
60
  test('StreamingToolParser: preserves tags in non-tool text', () => {
64
61
  const parser = new StreamingToolParser();
65
-
66
- const res1 = parser.feed('Fake: <tool_call> { "only_args": 1 } </tool_call> ');
67
- assert.ok(res1.text.includes('<tool_call>'), 'Should contain start tag');
68
- assert.ok(res1.text.includes('</tool_call>'), 'Should contain end tag');
62
+ const res1 = parser.feed(`Fake: ${TC_OPEN} { "only_args": 1 } ${TC_CLOSE} `);
63
+ assert.ok(res1.text.includes(TC_OPEN), 'Should contain start tag');
64
+ assert.ok(res1.text.includes(TC_CLOSE), 'Should contain close tag');
69
65
  assert.strictEqual(res1.toolCalls.length, 0);
70
66
 
71
- const res2 = parser.feed('Real: <tool_call>{"name":"r"}</tool_call>');
67
+ const res2 = parser.feed(`Real: ${TC_OPEN}{"name":"r"}${TC_CLOSE}`);
72
68
  assert.strictEqual(res2.toolCalls.length, 1);
73
69
  assert.strictEqual(res2.toolCalls[0].name, 'r');
74
70
  });
75
71
 
76
72
  test('StreamingToolParser: handles multiple tool calls in array format', () => {
77
73
  const parser = new StreamingToolParser();
78
-
79
- const chunk = `<tool_call>[
74
+ const chunk = `${TC_OPEN}[
80
75
  {"name": "bash", "arguments": {"command": "ls", "description": "List files"}},
81
76
  {"name": "read", "arguments": {"path": "test.txt"}}
82
- ]</tool_call>`;
83
-
77
+ ]${TC_CLOSE}`;
84
78
  const result = parser.feed(chunk);
85
79
  assert.strictEqual(result.toolCalls.length, 2, 'Should extract both tool calls');
86
80
  assert.strictEqual(result.toolCalls[0].name, 'bash');
@@ -90,8 +84,7 @@ test('StreamingToolParser: handles multiple tool calls in array format', () => {
90
84
 
91
85
  test('StreamingToolParser: double-escaped quotes in JSON', () => {
92
86
  const parser = new StreamingToolParser();
93
-
94
- const input = '<tool_call>{\\"name\\": \\"edit\\", \\"arguments\\": {\\"filePath\\": \\"/tmp/test.txt\\", \\"content\\": \\"hello\\"}}</tool_call>';
87
+ const input = `${TC_OPEN}{\\"name\\": \\"edit\\", \\"arguments\\": {\\"filePath\\": \\"/tmp/test.txt\\", \\"content\\": \\"hello\\"}}${TC_CLOSE}`;
95
88
  const res = parser.feed(input);
96
89
  assert.strictEqual(res.toolCalls.length, 1);
97
90
  assert.strictEqual(res.toolCalls[0].name, 'edit');
@@ -100,8 +93,7 @@ test('StreamingToolParser: double-escaped quotes in JSON', () => {
100
93
 
101
94
  test('StreamingToolParser: double-escaped quotes in XML parameters', () => {
102
95
  const parser = new StreamingToolParser();
103
-
104
- const input = '<tool_call>\n<name>write</name>\n<parameter name=\\"content\\">&lt;div&gt;hello &amp; world&lt;/div&gt;</parameter>\n</tool_call>';
96
+ const input = `${TC_OPEN}\n<name>write</name>\n<parameter name=\\"content\\">&lt;div&gt;hello &amp; world&lt;/div&gt;</parameter>\n${TC_CLOSE}`;
105
97
  const res = parser.feed(input);
106
98
  assert.strictEqual(res.toolCalls.length, 1);
107
99
  assert.strictEqual(res.toolCalls[0].name, 'write');
@@ -110,8 +102,7 @@ test('StreamingToolParser: double-escaped quotes in XML parameters', () => {
110
102
 
111
103
  test('StreamingToolParser: truncated JSON with unclosed string', () => {
112
104
  const parser = new StreamingToolParser();
113
-
114
- const res = parser.feed('<tool_call>{"name": "bash", "arguments": {"command": "echo hello</tool_call>');
105
+ const res = parser.feed(`${TC_OPEN}{"name": "bash", "arguments": {"command": "echo hello${TC_CLOSE}`);
115
106
  assert.strictEqual(res.toolCalls.length, 1);
116
107
  assert.strictEqual(res.toolCalls[0].name, 'bash');
117
108
  assert.strictEqual(typeof res.toolCalls[0].arguments.command, 'string');
@@ -119,9 +110,60 @@ test('StreamingToolParser: truncated JSON with unclosed string', () => {
119
110
 
120
111
  test('StreamingToolParser: flush double-escaped tool call', () => {
121
112
  const parser = new StreamingToolParser();
122
-
123
- parser.feed('<tool_call>{\\"name\\": \\"recover\\",\\"arguments\\": {\\"a\\": \\"val');
113
+ parser.feed(`${TC_OPEN}{\\"name\\": \\"recover\\",\\"arguments\\": {\\"a\\": \\"val`);
124
114
  const flushed = parser.flush();
125
115
  assert.strictEqual(flushed.toolCalls.length, 1);
126
116
  assert.strictEqual(flushed.toolCalls[0].name, 'recover');
127
117
  });
118
+
119
+ test('StreamingToolParser: handles literal close tag inside JSON string', () => {
120
+ const parser = new StreamingToolParser();
121
+ const toolCallJson = JSON.stringify({
122
+ name: "edit",
123
+ arguments: {
124
+ filePath: "/tmp/test.ts",
125
+ oldString: `some code with ${TC_CLOSE} inside a string value`,
126
+ newString: "replacement code"
127
+ }
128
+ });
129
+ const fullInput = `${TC_OPEN}${toolCallJson}${TC_CLOSE}`;
130
+ const res = parser.feed(fullInput);
131
+ assert.strictEqual(res.toolCalls.length, 1, 'Should parse the tool call despite to literal close tag in string');
132
+ assert.strictEqual(res.toolCalls[0].name, 'edit');
133
+ assert.strictEqual(res.toolCalls[0].arguments.filePath, '/tmp/test.ts');
134
+ assert.ok(
135
+ (res.toolCalls[0].arguments.oldString as string).includes(TC_CLOSE),
136
+ 'oldString should contain the literal close tag'
137
+ );
138
+ });
139
+
140
+ test('StreamingToolParser: unquoted arguments key with nested string values containing colons', () => {
141
+ const parser = new StreamingToolParser();
142
+ const input = `${TC_OPEN}{"name":"todowrite",arguments:{"todos":[{"content":"Add versions/activeVersionIndex to DB schema with migration","status":"completed","priority":"high"},{"content":"Update dbService to handle versions","status":"completed","priority":"high"},{"content":"Update ChatStore types and add regenerateMessage + switchVersion methods","status":"in_progress","priority":"high"},{"content":"Update Chat.tsx handleRegenerate to use new regenerateMessage","status":"pending"}]}}${TC_CLOSE}`;
143
+ const res = parser.feed(input);
144
+ assert.strictEqual(res.toolCalls.length, 1);
145
+ assert.strictEqual(res.toolCalls[0].name, 'todowrite');
146
+ assert.strictEqual((res.toolCalls[0].arguments.todos as any[]).length, 4);
147
+ assert.strictEqual((res.toolCalls[0].arguments.todos as any[])[2].status, 'in_progress');
148
+ });
149
+
150
+ test('StreamingToolParser: handles literal close tag in streamed chunks', () => {
151
+ const parser = new StreamingToolParser();
152
+ const toolCallJson = JSON.stringify({
153
+ name: "edit",
154
+ arguments: {
155
+ filePath: "/tmp/app.ts",
156
+ oldString: `function foo() { return "${TC_CLOSE}"; }`,
157
+ newString: "function bar() {}"
158
+ }
159
+ });
160
+ const fullInput = `${TC_OPEN}${toolCallJson}${TC_CLOSE}`;
161
+ const mid = Math.floor(fullInput.length / 2);
162
+ const chunk1 = fullInput.substring(0, mid);
163
+ const chunk2 = fullInput.substring(mid);
164
+
165
+ parser.feed(chunk1);
166
+ const res = parser.feed(chunk2);
167
+ assert.strictEqual(res.toolCalls.length, 1, 'Should parse across chunk boundaries');
168
+ assert.strictEqual(res.toolCalls[0].name, 'edit');
169
+ });
@@ -184,6 +184,8 @@ function findToolEndIndex(buffer: string): number {
184
184
  return -1;
185
185
  }
186
186
 
187
+
188
+
187
189
  // ─── Partial Tag Detection ─────────────────────────────────────────────────────
188
190
 
189
191
  const TOOL_START_LITERAL = '<tool_call>';
@@ -283,8 +285,9 @@ export class StreamingToolParser {
283
285
  break;
284
286
  }
285
287
  } else {
286
- const endIdx = this.buffer.indexOf(TOOL_END);
287
- if (endIdx !== -1) {
288
+ let endIdx = findToolEndIndex(this.buffer);
289
+ if (endIdx === -1) endIdx = this.buffer.indexOf(TOOL_END);
290
+ if (endIdx !== -1) {
288
291
  const content = this.buffer.substring(0, endIdx);
289
292
  this.buffer = this.buffer.substring(endIdx + TOOL_END.length);
290
293
  this.processToolContent(content, result);
@@ -292,7 +295,7 @@ export class StreamingToolParser {
292
295
  this.currentOpenTag = TOOL_START_LITERAL;
293
296
  if (this.buffer.length > 0) {
294
297
  const nextMatch = this.buffer.match(TOOL_OPEN_RE);
295
- if (nextMatch && nextMatch.index !== undefined) {
298
+ if (nextMatch && nextMatch.index !== undefined) {
296
299
  result.text += this.buffer.substring(0, nextMatch.index);
297
300
  this.insideTool = true;
298
301
  this.currentOpenTag = nextMatch[0];
@@ -305,7 +308,7 @@ export class StreamingToolParser {
305
308
  }
306
309
  }
307
310
  } else {
308
- break; // Wait for more data
311
+ break;
309
312
  }
310
313
  }
311
314
  }
@@ -1,8 +1,8 @@
1
- export function estimateTokenCount(text: string): number {
2
- // Divisor conservador (2.5) para evitar estouro silencioso do context window.
3
- // Tokenizers modernos (como o do Qwen) usam ~1.5 a 2.5 caracteres por token
4
- // para textos mistos (português, código, caracteres especiais).
5
- return Math.ceil(text.length / 2.5);
1
+ import { getModelTokenDivisor } from '../core/model-registry.js'
2
+
3
+ export function estimateTokenCount(text: string, modelId?: string): number {
4
+ const divisor = modelId ? getModelTokenDivisor(modelId) : 2.0
5
+ return Math.ceil(text.length / divisor)
6
6
  }
7
7
 
8
8
  function truncateSemantically(content: string, maxChars: number): string {
@@ -33,9 +33,11 @@ function truncateSemantically(content: string, maxChars: number): string {
33
33
  export function truncateMessages(
34
34
  messages: Array<{ role: string; content: string | null | any[] | Record<string, unknown> }>,
35
35
  maxContextLength: number,
36
- systemPrompt: string = ''
36
+ systemPrompt: string = '',
37
+ modelId?: string
37
38
  ): Array<{ role: string; content: string }> {
38
- const systemTokens = estimateTokenCount(systemPrompt);
39
+ const divisor = modelId ? getModelTokenDivisor(modelId) : 2.0
40
+ const systemTokens = estimateTokenCount(systemPrompt, modelId);
39
41
  const availableTokens = maxContextLength - systemTokens - 500;
40
42
 
41
43
  if (availableTokens <= 0) {
@@ -59,7 +61,7 @@ export function truncateMessages(
59
61
 
60
62
  for (let i = normalizedMessages.length - 1; i >= 0; i--) {
61
63
  const msg = normalizedMessages[i];
62
- const msgTokens = estimateTokenCount(msg.content);
64
+ const msgTokens = estimateTokenCount(msg.content, modelId);
63
65
 
64
66
  if (usedTokens + msgTokens <= availableTokens) {
65
67
  result.push(msg);
@@ -67,7 +69,7 @@ export function truncateMessages(
67
69
  } else {
68
70
  const remainingTokens = availableTokens - usedTokens;
69
71
  if (remainingTokens > 100) {
70
- const maxChars = Math.floor(remainingTokens * 2.5);
72
+ const maxChars = Math.floor(remainingTokens * divisor);
71
73
  const truncatedContent = truncateSemantically(msg.content, maxChars);
72
74
  result.push({ role: msg.role, content: `[Truncated] ${truncatedContent}` });
73
75
  }
@@ -77,7 +79,7 @@ export function truncateMessages(
77
79
 
78
80
  if (result.length === 0 && normalizedMessages.length > 0) {
79
81
  const lastMsg = normalizedMessages[normalizedMessages.length - 1];
80
- const maxChars = Math.max(200, Math.floor(availableTokens * 2.5));
82
+ const maxChars = Math.max(200, Math.floor(availableTokens * divisor));
81
83
  const truncatedContent = truncateSemantically(lastMsg.content, maxChars);
82
84
  result.push({ role: lastMsg.role, content: `[Truncated] ${truncatedContent}` });
83
85
  }
package/src/utils/json.ts CHANGED
@@ -59,6 +59,124 @@ function closeBraces(input: string, openBraces: number, openBrackets: number, in
59
59
  return out;
60
60
  }
61
61
 
62
+ function quoteUnquotedStringValues(input: string): string {
63
+ let out = '';
64
+ let i = 0;
65
+ let inString = false;
66
+ let escaped = false;
67
+
68
+ while (i < input.length) {
69
+ const ch = input[i];
70
+
71
+ if (escaped) { out += ch; escaped = false; i++; continue; }
72
+ if (ch === '\\' && inString) { out += ch; escaped = true; i++; continue; }
73
+ if (ch === '"') { inString = !inString; out += ch; i++; continue; }
74
+ if (inString) { out += ch; i++; continue; }
75
+
76
+ if (ch === ':') {
77
+ out += ch;
78
+ i++;
79
+ let ws = '';
80
+ while (i < input.length && /\s/.test(input[i])) { ws += input[i]; i++; }
81
+ out += ws;
82
+ if (i >= input.length) break;
83
+
84
+ const next = input[i];
85
+ if (next === '"' || next === '{' || next === '[' || next === '-' || /[0-9]/.test(next)) {
86
+ continue;
87
+ }
88
+ const rest = input.substring(i);
89
+ if (/^(true|false|null)\b/.test(rest)) {
90
+ continue;
91
+ }
92
+
93
+ let val = '';
94
+ let depthBrace = 0;
95
+ let depthBracket = 0;
96
+ let j = i;
97
+ while (j < input.length) {
98
+ const c = input[j];
99
+ if (c === '{') depthBrace++;
100
+ else if (c === '}') {
101
+ if (depthBrace === 0) break;
102
+ depthBrace--;
103
+ } else if (c === '[') depthBracket++;
104
+ else if (c === ']') {
105
+ if (depthBracket === 0) break;
106
+ depthBracket--;
107
+ } else if (c === ',' && depthBrace === 0 && depthBracket === 0) {
108
+ break;
109
+ }
110
+ val += c;
111
+ j++;
112
+ }
113
+
114
+ if (val.length > 0) {
115
+ const escapedVal = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
116
+ out += '"' + escapedVal + '"';
117
+ }
118
+ i = j;
119
+ continue;
120
+ }
121
+
122
+ out += ch;
123
+ i++;
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function quoteUnquotedKeys(input: string): string {
129
+ let out = '';
130
+ let inString = false;
131
+ let escaped = false;
132
+
133
+ for (let i = 0; i < input.length; i++) {
134
+ const ch = input[i];
135
+
136
+ if (escaped) {
137
+ out += ch;
138
+ escaped = false;
139
+ continue;
140
+ }
141
+
142
+ if (ch === '\\') {
143
+ out += ch;
144
+ escaped = true;
145
+ continue;
146
+ }
147
+
148
+ if (ch === '"') {
149
+ inString = !inString;
150
+ out += ch;
151
+ continue;
152
+ }
153
+
154
+ if (inString) {
155
+ out += ch;
156
+ continue;
157
+ }
158
+
159
+ if (/[a-zA-Z_]/.test(ch)) {
160
+ let j = i;
161
+ while (j < input.length && /[a-zA-Z0-9_]/.test(input[j])) j++;
162
+ const ident = input.slice(i, j);
163
+ let k = j;
164
+ while (k < input.length && /\s/.test(input[k])) k++;
165
+ if (k < input.length && input[k] === ':') {
166
+ out += '"' + ident + '"';
167
+ } else {
168
+ out += ident;
169
+ }
170
+ i = j - 1;
171
+ continue;
172
+ }
173
+
174
+ out += ch;
175
+ }
176
+
177
+ return out;
178
+ }
179
+
62
180
  export function robustParseJSON(str: string): any {
63
181
  let sanitized = str.trim();
64
182
  sanitized = sanitized.replace(/^```json\s*/, '').replace(/```$/, '').trim();
@@ -69,7 +187,8 @@ export function robustParseJSON(str: string): any {
69
187
  let jsonPart = sanitized.substring(firstBrace);
70
188
  try { return JSON.parse(jsonPart); } catch (e) { /* continue */ }
71
189
 
72
- let currentJson = jsonPart.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, '$1"$2"$3');
190
+ let currentJson = quoteUnquotedKeys(jsonPart);
191
+ currentJson = quoteUnquotedStringValues(currentJson);
73
192
  currentJson = currentJson.replace(/([{,]\s*)"([a-zA-Z0-9_]+)"\s*:\s*"\2"\s*:/g, '$1"$2":');
74
193
  currentJson = currentJson.replace(/([{,]\s*)([a-zA-Z0-9_]+)\s*:\s*\2\s*:/g, '$1$2:');
75
194
 
@@ -106,7 +225,7 @@ export function robustParseJSON(str: string): any {
106
225
 
107
226
  try { return JSON.parse(tempJson); } catch (e) {
108
227
  let aggressive = fixedJson.trim();
109
- if (aggressive.endsWith(',')) aggressive = aggressive.slice(0, -1);
228
+ aggressive = aggressive.replace(/,\s*([}\]])/g, '$1');
110
229
  const { result: aggFixed, openBraces: ob, openBrackets: bk, inString: aggInString } = sanitizeAndBalance(aggressive);
111
230
  try { return JSON.parse(closeBraces(aggFixed, ob, bk, aggInString)); } catch {
112
231
  return null;