@pedrofariasx/qwenproxy 1.2.3 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/models.ts +3 -1
- package/src/core/model-registry.ts +56 -9
- package/src/routes/chat.ts +5 -19
- package/src/services/playwright.ts +64 -19
- package/src/services/qwen.ts +132 -7
- package/src/tests/jsonFix.test.ts +17 -0
- package/src/tests/parser.test.ts +73 -31
- package/src/tools/parser.ts +7 -4
- package/src/utils/context-truncation.ts +12 -10
- package/src/utils/json.ts +121 -2
package/package.json
CHANGED
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
|
-
|
|
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
|
-
'
|
|
3
|
-
'
|
|
4
|
-
'
|
|
5
|
-
'
|
|
6
|
-
'
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'
|
|
10
|
-
'
|
|
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) {
|
package/src/routes/chat.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -68,6 +68,49 @@ const REFRESH_THRESHOLD = 0.7;
|
|
|
68
68
|
|
|
69
69
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
70
70
|
|
|
71
|
+
function getStealthScript(): string {
|
|
72
|
+
return `
|
|
73
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
74
|
+
Object.defineProperty(navigator, 'plugins', {
|
|
75
|
+
get: () => [1, 2, 3, 4, 5],
|
|
76
|
+
});
|
|
77
|
+
Object.defineProperty(navigator, 'languages', {
|
|
78
|
+
get: () => ['pt-BR', 'pt', 'en-US', 'en'],
|
|
79
|
+
});
|
|
80
|
+
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
|
81
|
+
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
|
82
|
+
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
|
83
|
+
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
|
|
84
|
+
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
|
|
85
|
+
window.chrome = {
|
|
86
|
+
runtime: {},
|
|
87
|
+
loadTimes: function() {},
|
|
88
|
+
csi: function() {},
|
|
89
|
+
app: {},
|
|
90
|
+
};
|
|
91
|
+
const originalQuery = window.navigator.permissions.query;
|
|
92
|
+
window.navigator.permissions.query = (parameters) =>
|
|
93
|
+
parameters.name === 'notifications'
|
|
94
|
+
? Promise.resolve({ state: Notification.permission })
|
|
95
|
+
: originalQuery(parameters);
|
|
96
|
+
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
|
97
|
+
WebGLRenderingContext.prototype.getParameter = function(parameter) {
|
|
98
|
+
if (parameter === 37445) return 'Intel Inc.';
|
|
99
|
+
if (parameter === 37446) return 'Intel Iris OpenGL Engine';
|
|
100
|
+
return getParameter.apply(this, arguments);
|
|
101
|
+
};
|
|
102
|
+
Object.defineProperty(navigator, 'connection', {
|
|
103
|
+
get: () => ({
|
|
104
|
+
effectiveType: '4g',
|
|
105
|
+
rtt: 50,
|
|
106
|
+
downlink: 10,
|
|
107
|
+
saveData: false,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
delete navigator.__proto__.webdriver;
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
71
114
|
export class Mutex {
|
|
72
115
|
private queue: (() => void)[] = [];
|
|
73
116
|
private locked = false;
|
|
@@ -169,15 +212,15 @@ export async function initPlaywright(headless = true, browserType: BrowserType =
|
|
|
169
212
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
170
213
|
ignoreDefaultArgs: ['--enable-automation'],
|
|
171
214
|
args: [
|
|
172
|
-
'--disable-blink-features=AutomationControlled'
|
|
215
|
+
'--disable-blink-features=AutomationControlled',
|
|
216
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
217
|
+
'--disable-infobars',
|
|
218
|
+
'--no-first-run',
|
|
219
|
+
'--no-default-browser-check',
|
|
173
220
|
]
|
|
174
221
|
});
|
|
175
222
|
|
|
176
|
-
await context.addInitScript(()
|
|
177
|
-
Object.defineProperty(navigator, 'webdriver', {
|
|
178
|
-
get: () => undefined,
|
|
179
|
-
});
|
|
180
|
-
});
|
|
223
|
+
await context.addInitScript(getStealthScript());
|
|
181
224
|
|
|
182
225
|
activePage = await context.newPage();
|
|
183
226
|
|
|
@@ -302,7 +345,9 @@ export async function getQwenHeaders(forceNew = false, accountId?: string): Prom
|
|
|
302
345
|
if (age < HEADERS_TTL) {
|
|
303
346
|
if (age > HEADERS_TTL * REFRESH_THRESHOLD && !cache.refreshInProgress) {
|
|
304
347
|
cache.refreshInProgress = true;
|
|
305
|
-
getQwenHeaders(true, accountId).
|
|
348
|
+
getQwenHeaders(true, accountId).catch((err) => {
|
|
349
|
+
console.warn(`[Playwright] Background header refresh failed for ${cacheKey}:`, (err as Error).message);
|
|
350
|
+
}).finally(() => {
|
|
306
351
|
cache.refreshInProgress = false;
|
|
307
352
|
});
|
|
308
353
|
}
|
|
@@ -584,15 +629,15 @@ export async function initPlaywrightForAccount(account: QwenAccount, headless =
|
|
|
584
629
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
585
630
|
ignoreDefaultArgs: ['--enable-automation'],
|
|
586
631
|
args: [
|
|
587
|
-
'--disable-blink-features=AutomationControlled'
|
|
632
|
+
'--disable-blink-features=AutomationControlled',
|
|
633
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
634
|
+
'--disable-infobars',
|
|
635
|
+
'--no-first-run',
|
|
636
|
+
'--no-default-browser-check',
|
|
588
637
|
]
|
|
589
638
|
});
|
|
590
639
|
|
|
591
|
-
await acctContext.addInitScript(()
|
|
592
|
-
Object.defineProperty(navigator, 'webdriver', {
|
|
593
|
-
get: () => undefined,
|
|
594
|
-
});
|
|
595
|
-
});
|
|
640
|
+
await acctContext.addInitScript(getStealthScript());
|
|
596
641
|
|
|
597
642
|
const acctPage = await acctContext.newPage();
|
|
598
643
|
accountContexts.set(account.id, acctContext);
|
|
@@ -616,15 +661,15 @@ export async function launchManualLoginAccount(accountId: string, browserType: B
|
|
|
616
661
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
617
662
|
ignoreDefaultArgs: ['--enable-automation'],
|
|
618
663
|
args: [
|
|
619
|
-
'--disable-blink-features=AutomationControlled'
|
|
664
|
+
'--disable-blink-features=AutomationControlled',
|
|
665
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
666
|
+
'--disable-infobars',
|
|
667
|
+
'--no-first-run',
|
|
668
|
+
'--no-default-browser-check',
|
|
620
669
|
]
|
|
621
670
|
});
|
|
622
671
|
|
|
623
|
-
await acctContext.addInitScript(()
|
|
624
|
-
Object.defineProperty(navigator, 'webdriver', {
|
|
625
|
-
get: () => undefined,
|
|
626
|
-
});
|
|
627
|
-
});
|
|
672
|
+
await acctContext.addInitScript(getStealthScript());
|
|
628
673
|
|
|
629
674
|
const acctPage = await acctContext.newPage();
|
|
630
675
|
await acctPage.goto('https://chat.qwen.ai/auth', { waitUntil: 'domcontentloaded' });
|
package/src/services/qwen.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
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;
|
|
8
|
+
|
|
9
|
+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
10
|
+
|
|
11
|
+
function getClientHintsHeaders(): Record<string, string> {
|
|
12
|
+
return {
|
|
13
|
+
'sec-ch-ua': '"Chromium";v="130", "Google Chrome";v="130", "Not?A_Brand";v="99"',
|
|
14
|
+
'sec-ch-ua-mobile': '?0',
|
|
15
|
+
'sec-ch-ua-platform': '"Windows"',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getRandomDelay(): number {
|
|
20
|
+
return 200 + Math.floor(Math.random() * 600);
|
|
21
|
+
}
|
|
5
22
|
|
|
6
23
|
export class RetryableQwenStreamError extends Error {
|
|
7
24
|
readonly retryAfterMs: number;
|
|
@@ -80,11 +97,13 @@ function cleanupStalePool(accountId: string) {
|
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, string>> {
|
|
83
|
-
const { cookie, userAgent, bxV } = await getBasicHeaders(accountId);
|
|
100
|
+
const { cookie, userAgent, bxV, bxUa, bxUmidtoken } = await getBasicHeaders(accountId);
|
|
84
101
|
return {
|
|
85
102
|
cookie,
|
|
86
103
|
'user-agent': userAgent,
|
|
87
104
|
'bx-v': bxV,
|
|
105
|
+
'bx-ua': bxUa || '',
|
|
106
|
+
'bx-umidtoken': bxUmidtoken || '',
|
|
88
107
|
};
|
|
89
108
|
}
|
|
90
109
|
|
|
@@ -101,6 +120,9 @@ async function createRealQwenChat(header: Record<string, string>): Promise<strin
|
|
|
101
120
|
'user-agent': header['user-agent'],
|
|
102
121
|
'x-request-id': crypto.randomUUID(),
|
|
103
122
|
'bx-v': header['bx-v'],
|
|
123
|
+
'bx-ua': header['bx-ua'] || '',
|
|
124
|
+
'bx-umidtoken': header['bx-umidtoken'] || '',
|
|
125
|
+
...getClientHintsHeaders(),
|
|
104
126
|
},
|
|
105
127
|
body: JSON.stringify({
|
|
106
128
|
title: 'Nova Conversa',
|
|
@@ -129,7 +151,19 @@ async function refillPoolForAccount(accountId: string) {
|
|
|
129
151
|
|
|
130
152
|
let headers: Record<string, string>;
|
|
131
153
|
try {
|
|
132
|
-
|
|
154
|
+
const acctId = accountId === 'global' ? undefined : accountId;
|
|
155
|
+
try {
|
|
156
|
+
const { headers: fullHeaders } = await getQwenHeaders(false, acctId);
|
|
157
|
+
headers = {
|
|
158
|
+
cookie: fullHeaders['cookie'] || '',
|
|
159
|
+
'user-agent': fullHeaders['user-agent'] || '',
|
|
160
|
+
'bx-v': fullHeaders['bx-v'] || '',
|
|
161
|
+
'bx-ua': fullHeaders['bx-ua'] || '',
|
|
162
|
+
'bx-umidtoken': fullHeaders['bx-umidtoken'] || '',
|
|
163
|
+
};
|
|
164
|
+
} catch {
|
|
165
|
+
headers = await getBasicQwenHeaders(acctId);
|
|
166
|
+
}
|
|
133
167
|
} catch (err) {
|
|
134
168
|
console.error(`[WarmPool] header fetch failed for ${accountId}:`, (err as Error).message);
|
|
135
169
|
return;
|
|
@@ -162,7 +196,15 @@ export async function getWarmedChat(accountId?: string) {
|
|
|
162
196
|
}
|
|
163
197
|
await refillPromises.get(key);
|
|
164
198
|
}
|
|
165
|
-
if (pool.length === 0)
|
|
199
|
+
if (pool.length === 0) {
|
|
200
|
+
// Retry once with short backoff if pool is still empty after first refill attempt
|
|
201
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
202
|
+
if (!refillPromises.has(key)) {
|
|
203
|
+
refillPromises.set(key, refillPoolForAccount(key).finally(() => refillPromises.delete(key)));
|
|
204
|
+
}
|
|
205
|
+
await refillPromises.get(key);
|
|
206
|
+
}
|
|
207
|
+
if (pool.length === 0) throw new Error(`Warm pool empty after retry for ${key}`);
|
|
166
208
|
return pool.shift()!;
|
|
167
209
|
}
|
|
168
210
|
|
|
@@ -284,7 +326,7 @@ export async function fetchQwenModels(accountId?: string): Promise<any[]> {
|
|
|
284
326
|
return cachedModels;
|
|
285
327
|
}
|
|
286
328
|
|
|
287
|
-
const { cookie, userAgent, bxV } = await getBasicHeaders(accountId);
|
|
329
|
+
const { cookie, userAgent, bxV, bxUa, bxUmidtoken } = await getBasicHeaders(accountId);
|
|
288
330
|
|
|
289
331
|
const response = await fetch('https://chat.qwen.ai/api/models', {
|
|
290
332
|
headers: {
|
|
@@ -295,8 +337,11 @@ export async function fetchQwenModels(accountId?: string): Promise<any[]> {
|
|
|
295
337
|
'user-agent': userAgent,
|
|
296
338
|
'x-request-id': crypto.randomUUID(),
|
|
297
339
|
'bx-v': bxV,
|
|
340
|
+
'bx-ua': bxUa || '',
|
|
341
|
+
'bx-umidtoken': bxUmidtoken || '',
|
|
298
342
|
'timezone': CACHED_TIMEZONE,
|
|
299
|
-
'source': 'web'
|
|
343
|
+
'source': 'web',
|
|
344
|
+
...getClientHintsHeaders(),
|
|
300
345
|
}
|
|
301
346
|
});
|
|
302
347
|
|
|
@@ -395,6 +440,7 @@ export async function createQwenStream(
|
|
|
395
440
|
}
|
|
396
441
|
} catch (err: any) {
|
|
397
442
|
console.error('[Qwen] Failed to process multimodal uploads:', err.message);
|
|
443
|
+
throw new Error(`Multimodal upload failed: ${err.message}`);
|
|
398
444
|
}
|
|
399
445
|
}
|
|
400
446
|
|
|
@@ -443,9 +489,18 @@ export async function createQwenStream(
|
|
|
443
489
|
timestamp: timestamp + 1
|
|
444
490
|
};
|
|
445
491
|
|
|
492
|
+
const payloadJson = JSON.stringify(payload);
|
|
493
|
+
const payloadSize = Buffer.byteLength(payloadJson);
|
|
494
|
+
if (payloadSize > MAX_PAYLOAD_SIZE) {
|
|
495
|
+
throw new Error(`Payload too large: ${payloadSize} bytes exceeds limit of ${MAX_PAYLOAD_SIZE} bytes`);
|
|
496
|
+
}
|
|
497
|
+
const payloadMB = payloadSize / (1024 * 1024);
|
|
498
|
+
const timeoutMs = BASE_TIMEOUT_MS + Math.ceil(payloadMB * TIMEOUT_PER_MB);
|
|
499
|
+
|
|
446
500
|
const url = `https://chat.qwen.ai/api/v2/chat/completions?chat_id=${chatId}`;
|
|
447
501
|
const controller = new AbortController();
|
|
448
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
502
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
503
|
+
await sleep(getRandomDelay());
|
|
449
504
|
const response = await fetch(url, {
|
|
450
505
|
method: 'POST',
|
|
451
506
|
headers: {
|
|
@@ -463,12 +518,82 @@ export async function createQwenStream(
|
|
|
463
518
|
'x-accel-buffering': 'no',
|
|
464
519
|
'x-request-id': crypto.randomUUID(),
|
|
465
520
|
'bx-v': chatHeaders['bx-v'],
|
|
521
|
+
'bx-ua': chatHeaders['bx-ua'] || '',
|
|
522
|
+
'bx-umidtoken': chatHeaders['bx-umidtoken'] || '',
|
|
523
|
+
...getClientHintsHeaders(),
|
|
466
524
|
},
|
|
467
|
-
body:
|
|
525
|
+
body: payloadJson,
|
|
468
526
|
signal: controller.signal
|
|
469
527
|
});
|
|
470
528
|
clearTimeout(timeoutId);
|
|
471
529
|
|
|
530
|
+
const responseContentType = response.headers.get('content-type') || '';
|
|
531
|
+
if (response.ok && responseContentType.includes('application/json') && response.body) {
|
|
532
|
+
const cloned = response.clone();
|
|
533
|
+
const peekText = await cloned.text().catch(() => '');
|
|
534
|
+
if (peekText.includes('FAIL_SYS_USER_VALIDATE') || peekText.includes('_____tmd_____') || peekText.includes('RGV587_ERROR')) {
|
|
535
|
+
console.warn('[Qwen] TMD challenge detected, refreshing headers and retrying...');
|
|
536
|
+
try {
|
|
537
|
+
const { headers: freshHeaders } = await getQwenHeaders(true, accountId);
|
|
538
|
+
await sleep(1000 + Math.floor(Math.random() * 2000));
|
|
539
|
+
const retryController = new AbortController();
|
|
540
|
+
const retryTimeoutId = setTimeout(() => retryController.abort(), timeoutMs);
|
|
541
|
+
const retryResponse = await fetch(url, {
|
|
542
|
+
method: 'POST',
|
|
543
|
+
headers: {
|
|
544
|
+
'accept': 'application/json',
|
|
545
|
+
'accept-language': 'pt-BR,pt;q=0.9',
|
|
546
|
+
'content-type': 'application/json',
|
|
547
|
+
'cookie': freshHeaders['cookie'],
|
|
548
|
+
'origin': 'https://chat.qwen.ai',
|
|
549
|
+
'referer': `https://chat.qwen.ai/c/${chatId}`,
|
|
550
|
+
'sec-fetch-dest': 'empty',
|
|
551
|
+
'sec-fetch-mode': 'cors',
|
|
552
|
+
'sec-fetch-site': 'same-origin',
|
|
553
|
+
'timezone': CACHED_TIMEZONE,
|
|
554
|
+
'user-agent': freshHeaders['user-agent'],
|
|
555
|
+
'x-accel-buffering': 'no',
|
|
556
|
+
'x-request-id': crypto.randomUUID(),
|
|
557
|
+
'bx-v': freshHeaders['bx-v'],
|
|
558
|
+
'bx-ua': freshHeaders['bx-ua'] || '',
|
|
559
|
+
'bx-umidtoken': freshHeaders['bx-umidtoken'] || '',
|
|
560
|
+
...getClientHintsHeaders(),
|
|
561
|
+
},
|
|
562
|
+
body: payloadJson,
|
|
563
|
+
signal: retryController.signal
|
|
564
|
+
});
|
|
565
|
+
clearTimeout(retryTimeoutId);
|
|
566
|
+
|
|
567
|
+
const retryContentType = retryResponse.headers.get('content-type') || '';
|
|
568
|
+
if (retryResponse.ok && retryContentType.includes('text/event-stream') && retryResponse.body) {
|
|
569
|
+
return { stream: retryResponse.body, headers: freshHeaders, uiSessionId: chatId, controller: retryController, accountId: chatEntry.accountId };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const retryPeek = await retryResponse.clone().text().catch(() => '');
|
|
573
|
+
if (retryPeek.includes('FAIL_SYS_USER_VALIDATE') || retryPeek.includes('_____tmd_____')) {
|
|
574
|
+
throw new QwenUpstreamError(
|
|
575
|
+
'Qwen TMD challenge persists after header refresh. The account may need manual captcha resolution.',
|
|
576
|
+
'FAIL_SYS_USER_VALIDATE',
|
|
577
|
+
403,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (retryResponse.ok && retryResponse.body) {
|
|
582
|
+
return { stream: retryResponse.body, headers: freshHeaders, uiSessionId: chatId, controller: retryController, accountId: chatEntry.accountId };
|
|
583
|
+
}
|
|
584
|
+
} catch (retryErr) {
|
|
585
|
+
if (retryErr instanceof QwenUpstreamError) throw retryErr;
|
|
586
|
+
console.error('[Qwen] TMD retry failed:', (retryErr as Error).message);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
throw new QwenUpstreamError(
|
|
590
|
+
'Qwen TMD anti-bot challenge detected. Headers were refreshed but the challenge persists.',
|
|
591
|
+
'FAIL_SYS_USER_VALIDATE',
|
|
592
|
+
403,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
472
597
|
if (!response.ok || !response.body) {
|
|
473
598
|
const errText = await response.text().catch(() => '');
|
|
474
599
|
const contentType = response.headers.get('content-type') || '';
|
|
@@ -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
|
+
});
|
package/src/tests/parser.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
47
|
+
parser3.feed(`Invalid ${TC_OPEN}NOT_JSON`);
|
|
50
48
|
const flushed2 = parser3.flush();
|
|
51
|
-
assert.strictEqual(flushed2.text,
|
|
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
|
-
|
|
67
|
-
assert.ok(res1.text.includes(
|
|
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(
|
|
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
|
-
]
|
|
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\\"><div>hello & world</div></parameter>\n</tool_call>';
|
|
96
|
+
const input = `${TC_OPEN}\n<name>write</name>\n<parameter name=\\"content\\"><div>hello & world</div></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
|
+
});
|
package/src/tools/parser.ts
CHANGED
|
@@ -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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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;
|
|
311
|
+
break;
|
|
309
312
|
}
|
|
310
313
|
}
|
|
311
314
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
return Math.ceil(text.length /
|
|
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
|
|
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 *
|
|
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 *
|
|
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
|
|
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
|
-
|
|
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;
|