@pedrofariasx/qwenproxy 1.2.1 → 1.2.3
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/README.md +3 -13
- package/package.json +1 -1
- package/src/api/server.ts +4 -6
- package/src/cache/memory-cache.ts +5 -3
- package/src/core/account-manager.ts +1 -1
- package/src/core/accounts.ts +1 -1
- package/src/login.ts +2 -2
- package/src/routes/chat.ts +122 -91
- package/src/routes/upload.ts +5 -5
- package/src/services/playwright.ts +40 -120
- package/src/services/qwen.ts +29 -27
- package/src/tests/concurrency.test.ts +1 -1
- package/src/tests/concurrentChat.test.ts +1 -1
- package/src/tests/contextTruncation.test.ts +142 -0
- package/src/tests/delta.test.ts +80 -10
- package/src/tests/jsonFix.test.ts +110 -98
- package/src/tests/multimodal.test.ts +1 -1
- package/src/tests/parser.test.ts +40 -2
- package/src/tools/parser.ts +98 -33
- package/src/utils/context-truncation.ts +1 -6
- package/src/utils/json.ts +9 -8
- package/src/utils/types.ts +1 -1
- package/src/linter/extraction-engine.ts +0 -165
- package/src/linter/index.ts +0 -258
- package/src/linter/repair-normalize.ts +0 -245
- package/src/linter/safety-gate.ts +0 -219
- package/src/linter/streaming-state-machine.ts +0 -252
- package/src/linter/structural-parser.ts +0 -352
- package/src/linter/types.ts +0 -74
- package/src/tests/linter.test.ts +0 -151
- package/src/tests/parallel.test.ts +0 -42
- package/src/tests/structureVerification.test.ts +0 -176
- package/src/tools/ast.ts +0 -15
- package/src/tools/coercion.ts +0 -67
- package/src/tools/confidence.ts +0 -48
- package/src/tools/detector.ts +0 -40
- package/src/tools/executor.ts +0 -236
- package/src/tools/pipeline.ts +0 -122
- package/src/tools/registry-runtime.ts +0 -34
- package/src/tools/repair.ts +0 -42
- package/src/tools/validator.ts +0 -33
|
@@ -11,11 +11,27 @@
|
|
|
11
11
|
import { chromium, firefox, webkit, BrowserContext, Page } from 'playwright';
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import crypto from 'crypto';
|
|
14
|
-
import { QwenAccount } from '../core/accounts.
|
|
15
|
-
import { config } from '../core/config.
|
|
14
|
+
import { QwenAccount } from '../core/accounts.js';
|
|
15
|
+
import { config } from '../core/config.js';
|
|
16
16
|
|
|
17
17
|
export type BrowserType = 'chromium' | 'firefox' | 'webkit' | 'chrome' | 'edge';
|
|
18
18
|
|
|
19
|
+
interface BrowserEngineConfig {
|
|
20
|
+
engine: typeof chromium | typeof firefox | typeof webkit;
|
|
21
|
+
channel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveBrowserEngine(browserType: BrowserType): BrowserEngineConfig {
|
|
25
|
+
switch (browserType) {
|
|
26
|
+
case 'firefox': return { engine: firefox };
|
|
27
|
+
case 'webkit': return { engine: webkit };
|
|
28
|
+
case 'chrome': return { engine: chromium, channel: 'chrome' };
|
|
29
|
+
case 'edge': return { engine: chromium, channel: 'msedge' };
|
|
30
|
+
case 'chromium':
|
|
31
|
+
default: return { engine: chromium };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
let context: BrowserContext | null = null;
|
|
20
36
|
export let activePage: Page | null = null;
|
|
21
37
|
const accountContexts = new Map<string, BrowserContext>();
|
|
@@ -29,6 +45,7 @@ interface AccountHeaderCache {
|
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
const accountHeaderCaches = new Map<string, AccountHeaderCache>();
|
|
48
|
+
const cachedUserAgents = new Map<string, string>();
|
|
32
49
|
|
|
33
50
|
function getAccountHeaderCache(accountId: string): AccountHeaderCache {
|
|
34
51
|
let cache = accountHeaderCaches.get(accountId);
|
|
@@ -119,9 +136,14 @@ export async function getBasicHeaders(accountId?: string): Promise<{ cookie: str
|
|
|
119
136
|
if (!page) throw new Error('Playwright not initialized');
|
|
120
137
|
|
|
121
138
|
const cookie = await getCookies(accountId);
|
|
122
|
-
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
123
|
-
|
|
124
139
|
const cacheKey = accountId || 'global';
|
|
140
|
+
|
|
141
|
+
let userAgent = cachedUserAgents.get(cacheKey);
|
|
142
|
+
if (!userAgent) {
|
|
143
|
+
userAgent = await page.evaluate(() => navigator.userAgent);
|
|
144
|
+
cachedUserAgents.set(cacheKey, userAgent);
|
|
145
|
+
}
|
|
146
|
+
|
|
125
147
|
const cache = getAccountHeaderCache(cacheKey);
|
|
126
148
|
const bxV = cache.currentHeaders['bx-v'] || '2.5.36';
|
|
127
149
|
const bxUa = cache.currentHeaders['bx-ua'];
|
|
@@ -137,34 +159,11 @@ export async function initPlaywright(headless = true, browserType: BrowserType =
|
|
|
137
159
|
}
|
|
138
160
|
|
|
139
161
|
const profilePath = path.resolve('qwen_profiles', '_default');
|
|
140
|
-
|
|
141
|
-
let browserEngine;
|
|
142
|
-
let channel: string | undefined;
|
|
143
|
-
|
|
144
|
-
switch (browserType) {
|
|
145
|
-
case 'firefox':
|
|
146
|
-
browserEngine = firefox;
|
|
147
|
-
break;
|
|
148
|
-
case 'webkit':
|
|
149
|
-
browserEngine = webkit;
|
|
150
|
-
break;
|
|
151
|
-
case 'chrome':
|
|
152
|
-
browserEngine = chromium;
|
|
153
|
-
channel = 'chrome';
|
|
154
|
-
break;
|
|
155
|
-
case 'edge':
|
|
156
|
-
browserEngine = chromium;
|
|
157
|
-
channel = 'msedge';
|
|
158
|
-
break;
|
|
159
|
-
case 'chromium':
|
|
160
|
-
default:
|
|
161
|
-
browserEngine = chromium;
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
162
|
+
const { engine, channel } = resolveBrowserEngine(browserType);
|
|
164
163
|
|
|
165
164
|
console.log(`[Playwright] Launching ${browserType}...`);
|
|
166
165
|
|
|
167
|
-
context = await
|
|
166
|
+
context = await engine.launchPersistentContext(profilePath, {
|
|
168
167
|
headless,
|
|
169
168
|
channel,
|
|
170
169
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
@@ -248,45 +247,8 @@ export async function closePlaywright() {
|
|
|
248
247
|
|
|
249
248
|
export async function loginToQwen(email: string, password: string): Promise<boolean> {
|
|
250
249
|
if (!activePage) throw new Error('Playwright not initialized');
|
|
251
|
-
|
|
252
250
|
console.log(`[Playwright] Attempting API login for ${email}...`);
|
|
253
|
-
|
|
254
|
-
await activePage.goto('https://chat.qwen.ai/auth', { waitUntil: 'domcontentloaded' });
|
|
255
|
-
|
|
256
|
-
const hashedPassword = crypto.createHash('sha256').update(password).digest('hex');
|
|
257
|
-
|
|
258
|
-
const result = await activePage.evaluate(async ({ email, password }) => {
|
|
259
|
-
try {
|
|
260
|
-
const response = await fetch("https://chat.qwen.ai/api/v2/auths/signin", {
|
|
261
|
-
method: "POST",
|
|
262
|
-
headers: {
|
|
263
|
-
"accept": "application/json, text/plain, */*",
|
|
264
|
-
"content-type": "application/json",
|
|
265
|
-
"source": "web",
|
|
266
|
-
"timezone": new Date().toString().split(' (')[0],
|
|
267
|
-
"x-request-id": crypto.randomUUID()
|
|
268
|
-
},
|
|
269
|
-
body: JSON.stringify({ email, password, login_type: "email" })
|
|
270
|
-
});
|
|
271
|
-
const data = await response.json();
|
|
272
|
-
return { ok: response.ok, data };
|
|
273
|
-
} catch (e: any) {
|
|
274
|
-
return { ok: false, error: e.message };
|
|
275
|
-
}
|
|
276
|
-
}, { email, password: hashedPassword });
|
|
277
|
-
|
|
278
|
-
if (result.ok) {
|
|
279
|
-
console.log('[Playwright] API login request successful.');
|
|
280
|
-
await activePage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded' });
|
|
281
|
-
const isLogged = !(activePage.url().includes('auth') || activePage.url().includes('login'));
|
|
282
|
-
if (isLogged) {
|
|
283
|
-
console.log('[Playwright] Login confirmed.');
|
|
284
|
-
return true;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
console.error('[Playwright] Login failed:', result.data || result.error);
|
|
289
|
-
return false;
|
|
251
|
+
return loginToQwenWithContext(activePage.context(), activePage, email, password);
|
|
290
252
|
}
|
|
291
253
|
|
|
292
254
|
async function loginToQwenUI(email: string, password: string): Promise<boolean> {
|
|
@@ -373,7 +335,11 @@ async function tryLightweightCookieRefresh(accountId?: string): Promise<{ header
|
|
|
373
335
|
try {
|
|
374
336
|
const cookies = await page.context().cookies();
|
|
375
337
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
|
376
|
-
|
|
338
|
+
let userAgent = cachedUserAgents.get(cacheKey);
|
|
339
|
+
if (!userAgent) {
|
|
340
|
+
userAgent = await page.evaluate(() => navigator.userAgent);
|
|
341
|
+
cachedUserAgents.set(cacheKey, userAgent);
|
|
342
|
+
}
|
|
377
343
|
|
|
378
344
|
const now = Date.now();
|
|
379
345
|
cookieCaches.set(cacheKey, { cookie: cookieStr, timestamp: now });
|
|
@@ -472,7 +438,7 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
|
|
|
472
438
|
console.warn('[Playwright] Detected login page but QWEN_EMAIL/PASSWORD not provided in .env');
|
|
473
439
|
}
|
|
474
440
|
} else {
|
|
475
|
-
const { getAccountCredentials } = await import('../core/accounts.
|
|
441
|
+
const { getAccountCredentials } = await import('../core/accounts.js');
|
|
476
442
|
const creds = getAccountCredentials(accountId);
|
|
477
443
|
if (creds && creds.email && creds.password) {
|
|
478
444
|
console.log(`[Playwright] Detected login page for account ${creds.email}. Attempting login...`);
|
|
@@ -547,7 +513,7 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
|
|
|
547
513
|
cache.lastHeadersTime = Date.now();
|
|
548
514
|
cache.refreshInProgress = false;
|
|
549
515
|
|
|
550
|
-
import('./qwen.
|
|
516
|
+
import('./qwen.js').then(m => m.disableNativeTools(accountId).catch(() => {}));
|
|
551
517
|
|
|
552
518
|
await route.abort('aborted');
|
|
553
519
|
|
|
@@ -608,34 +574,11 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
|
|
|
608
574
|
|
|
609
575
|
export async function initPlaywrightForAccount(account: QwenAccount, headless = true, browserType: BrowserType = 'chromium') {
|
|
610
576
|
const profilePath = path.resolve('qwen_profiles', account.id);
|
|
611
|
-
|
|
612
|
-
let browserEngine;
|
|
613
|
-
let channel: string | undefined;
|
|
614
|
-
|
|
615
|
-
switch (browserType) {
|
|
616
|
-
case 'firefox':
|
|
617
|
-
browserEngine = firefox;
|
|
618
|
-
break;
|
|
619
|
-
case 'webkit':
|
|
620
|
-
browserEngine = webkit;
|
|
621
|
-
break;
|
|
622
|
-
case 'chrome':
|
|
623
|
-
browserEngine = chromium;
|
|
624
|
-
channel = 'chrome';
|
|
625
|
-
break;
|
|
626
|
-
case 'edge':
|
|
627
|
-
browserEngine = chromium;
|
|
628
|
-
channel = 'msedge';
|
|
629
|
-
break;
|
|
630
|
-
case 'chromium':
|
|
631
|
-
default:
|
|
632
|
-
browserEngine = chromium;
|
|
633
|
-
break;
|
|
634
|
-
}
|
|
577
|
+
const { engine, channel } = resolveBrowserEngine(browserType);
|
|
635
578
|
|
|
636
579
|
console.log(`[Playwright] Launching ${browserType} for account ${account.email}...`);
|
|
637
580
|
|
|
638
|
-
const acctContext = await
|
|
581
|
+
const acctContext = await engine.launchPersistentContext(profilePath, {
|
|
639
582
|
headless,
|
|
640
583
|
channel,
|
|
641
584
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
@@ -665,32 +608,9 @@ export async function initPlaywrightForAccount(account: QwenAccount, headless =
|
|
|
665
608
|
|
|
666
609
|
export async function launchManualLoginAccount(accountId: string, browserType: BrowserType = 'chromium'): Promise<{ context: BrowserContext, page: Page }> {
|
|
667
610
|
const profilePath = path.resolve('qwen_profiles', accountId);
|
|
668
|
-
|
|
669
|
-
let browserEngine;
|
|
670
|
-
let channel: string | undefined;
|
|
671
|
-
|
|
672
|
-
switch (browserType) {
|
|
673
|
-
case 'firefox':
|
|
674
|
-
browserEngine = firefox;
|
|
675
|
-
break;
|
|
676
|
-
case 'webkit':
|
|
677
|
-
browserEngine = webkit;
|
|
678
|
-
break;
|
|
679
|
-
case 'chrome':
|
|
680
|
-
browserEngine = chromium;
|
|
681
|
-
channel = 'chrome';
|
|
682
|
-
break;
|
|
683
|
-
case 'edge':
|
|
684
|
-
browserEngine = chromium;
|
|
685
|
-
channel = 'msedge';
|
|
686
|
-
break;
|
|
687
|
-
case 'chromium':
|
|
688
|
-
default:
|
|
689
|
-
browserEngine = chromium;
|
|
690
|
-
break;
|
|
691
|
-
}
|
|
611
|
+
const { engine, channel } = resolveBrowserEngine(browserType);
|
|
692
612
|
|
|
693
|
-
const acctContext = await
|
|
613
|
+
const acctContext = await engine.launchPersistentContext(profilePath, {
|
|
694
614
|
headless: false,
|
|
695
615
|
channel,
|
|
696
616
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
package/src/services/qwen.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { getQwenHeaders, getBasicHeaders } from './playwright.
|
|
2
|
-
import
|
|
1
|
+
import { getQwenHeaders, getBasicHeaders } from './playwright.js';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
|
|
4
|
+
const CACHED_TIMEZONE = new Date().toString().split(' (')[0];
|
|
3
5
|
|
|
4
6
|
export class RetryableQwenStreamError extends Error {
|
|
5
7
|
readonly retryAfterMs: number;
|
|
@@ -26,8 +28,7 @@ interface SessionEntry {
|
|
|
26
28
|
timestamp: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
const sessionStates: Map<string, SessionEntry> =
|
|
30
|
-
(globalThis as any)._sessionStates = sessionStates;
|
|
31
|
+
const sessionStates: Map<string, SessionEntry> = new Map();
|
|
31
32
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
32
33
|
|
|
33
34
|
function cleanupStaleSessions() {
|
|
@@ -63,11 +64,9 @@ interface WarmPoolEntry {
|
|
|
63
64
|
timestamp: number;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
const warmPool: Map<string, WarmPoolEntry[]> =
|
|
67
|
-
(globalThis as any)._warmPool = warmPool;
|
|
67
|
+
const warmPool: Map<string, WarmPoolEntry[]> = new Map();
|
|
68
68
|
|
|
69
|
-
const refillPromises: Map<string, Promise<void>> =
|
|
70
|
-
(globalThis as any)._refillPromises = refillPromises;
|
|
69
|
+
const refillPromises: Map<string, Promise<void>> = new Map();
|
|
71
70
|
|
|
72
71
|
const WARM_POOL_SIZE = 5;
|
|
73
72
|
const WARM_POOL_TTL_MS = 10 * 60 * 1000;
|
|
@@ -76,13 +75,11 @@ function cleanupStalePool(accountId: string) {
|
|
|
76
75
|
const pool = warmPool.get(accountId);
|
|
77
76
|
if (!pool) return;
|
|
78
77
|
const now = Date.now();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
78
|
+
const filtered = pool.filter(e => now - e.timestamp <= WARM_POOL_TTL_MS);
|
|
79
|
+
if (filtered.length !== pool.length) warmPool.set(accountId, filtered);
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, string>> {
|
|
85
|
-
const { getBasicHeaders } = await import('./playwright.ts');
|
|
86
83
|
const { cookie, userAgent, bxV } = await getBasicHeaders(accountId);
|
|
87
84
|
return {
|
|
88
85
|
cookie,
|
|
@@ -92,8 +89,6 @@ async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, s
|
|
|
92
89
|
}
|
|
93
90
|
|
|
94
91
|
async function createRealQwenChat(header: Record<string, string>): Promise<string> {
|
|
95
|
-
const controller = new AbortController();
|
|
96
|
-
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
97
92
|
const response = await fetch('https://chat.qwen.ai/api/v2/chats/new', {
|
|
98
93
|
method: 'POST',
|
|
99
94
|
headers: {
|
|
@@ -104,7 +99,7 @@ async function createRealQwenChat(header: Record<string, string>): Promise<strin
|
|
|
104
99
|
origin: 'https://chat.qwen.ai',
|
|
105
100
|
referer: 'https://chat.qwen.ai/c/new-chat',
|
|
106
101
|
'user-agent': header['user-agent'],
|
|
107
|
-
'x-request-id':
|
|
102
|
+
'x-request-id': crypto.randomUUID(),
|
|
108
103
|
'bx-v': header['bx-v'],
|
|
109
104
|
},
|
|
110
105
|
body: JSON.stringify({
|
|
@@ -115,9 +110,8 @@ async function createRealQwenChat(header: Record<string, string>): Promise<strin
|
|
|
115
110
|
timestamp: Date.now(),
|
|
116
111
|
project_id: '',
|
|
117
112
|
}),
|
|
118
|
-
signal:
|
|
113
|
+
signal: AbortSignal.timeout(30000),
|
|
119
114
|
});
|
|
120
|
-
clearTimeout(timeoutId);
|
|
121
115
|
|
|
122
116
|
if (!response.ok) throw new Error(`Failed to create chat: ${response.status}`);
|
|
123
117
|
const json = await response.json();
|
|
@@ -131,18 +125,26 @@ async function refillPoolForAccount(accountId: string) {
|
|
|
131
125
|
if (!pool) { pool = []; warmPool.set(accountId, pool); }
|
|
132
126
|
cleanupStalePool(accountId);
|
|
133
127
|
const need = Math.max(0, WARM_POOL_SIZE - pool.length);
|
|
134
|
-
|
|
128
|
+
if (need === 0) return;
|
|
129
|
+
|
|
130
|
+
let headers: Record<string, string>;
|
|
131
|
+
try {
|
|
132
|
+
headers = await getBasicQwenHeaders(accountId === 'global' ? undefined : accountId);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(`[WarmPool] header fetch failed for ${accountId}:`, (err as Error).message);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
135
138
|
const creationPromises = Array.from({ length: need }, async () => {
|
|
136
139
|
try {
|
|
137
|
-
const headers = await getBasicQwenHeaders(accountId === 'global' ? undefined : accountId);
|
|
138
140
|
const chatId = await createRealQwenChat(headers);
|
|
139
141
|
return { chatId, headers, accountId, timestamp: Date.now() };
|
|
140
142
|
} catch (err) {
|
|
141
|
-
console.error(`[WarmPool]
|
|
143
|
+
console.error(`[WarmPool] chat creation failed for ${accountId}:`, (err as Error).message);
|
|
142
144
|
return null;
|
|
143
145
|
}
|
|
144
146
|
});
|
|
145
|
-
|
|
147
|
+
|
|
146
148
|
const results = await Promise.all(creationPromises);
|
|
147
149
|
for (const entry of results) {
|
|
148
150
|
if (entry) pool.push(entry);
|
|
@@ -252,7 +254,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
252
254
|
'origin': 'https://chat.qwen.ai',
|
|
253
255
|
'referer': 'https://chat.qwen.ai/',
|
|
254
256
|
'user-agent': headers['user-agent'],
|
|
255
|
-
'x-request-id':
|
|
257
|
+
'x-request-id': crypto.randomUUID(),
|
|
256
258
|
'bx-ua': headers['bx-ua'],
|
|
257
259
|
'bx-umidtoken': headers['bx-umidtoken'],
|
|
258
260
|
'bx-v': headers['bx-v']
|
|
@@ -291,9 +293,9 @@ export async function fetchQwenModels(accountId?: string): Promise<any[]> {
|
|
|
291
293
|
'cookie': cookie,
|
|
292
294
|
'referer': 'https://chat.qwen.ai/',
|
|
293
295
|
'user-agent': userAgent,
|
|
294
|
-
'x-request-id':
|
|
296
|
+
'x-request-id': crypto.randomUUID(),
|
|
295
297
|
'bx-v': bxV,
|
|
296
|
-
'timezone':
|
|
298
|
+
'timezone': CACHED_TIMEZONE,
|
|
297
299
|
'source': 'web'
|
|
298
300
|
}
|
|
299
301
|
});
|
|
@@ -397,7 +399,7 @@ export async function createQwenStream(
|
|
|
397
399
|
}
|
|
398
400
|
|
|
399
401
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
400
|
-
const fid =
|
|
402
|
+
const fid = crypto.randomUUID();
|
|
401
403
|
const model = modelId.replace('-no-thinking', '');
|
|
402
404
|
|
|
403
405
|
const payload: QwenPayload = {
|
|
@@ -456,10 +458,10 @@ export async function createQwenStream(
|
|
|
456
458
|
'sec-fetch-dest': 'empty',
|
|
457
459
|
'sec-fetch-mode': 'cors',
|
|
458
460
|
'sec-fetch-site': 'same-origin',
|
|
459
|
-
'timezone':
|
|
461
|
+
'timezone': CACHED_TIMEZONE,
|
|
460
462
|
'user-agent': chatHeaders['user-agent'],
|
|
461
463
|
'x-accel-buffering': 'no',
|
|
462
|
-
'x-request-id':
|
|
464
|
+
'x-request-id': crypto.randomUUID(),
|
|
463
465
|
'bx-v': chatHeaders['bx-v'],
|
|
464
466
|
},
|
|
465
467
|
body: JSON.stringify(payload),
|
|
@@ -4,7 +4,7 @@ import assert from 'node:assert';
|
|
|
4
4
|
process.env.TEST_MOCK_PLAYWRIGHT = 'true';
|
|
5
5
|
|
|
6
6
|
import { app } from '../api/server.js';
|
|
7
|
-
import { initPlaywright, closePlaywright } from '../services/playwright.
|
|
7
|
+
import { initPlaywright, closePlaywright } from '../services/playwright.js';
|
|
8
8
|
|
|
9
9
|
test('Concurrent requests are serialized by mutex', async () => {
|
|
10
10
|
const originalFetch = globalThis.fetch;
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert';
|
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
import { serve } from '@hono/node-server';
|
|
5
5
|
import { app } from '../api/server.js';
|
|
6
|
-
import { initPlaywright, closePlaywright } from '../services/playwright.
|
|
6
|
+
import { initPlaywright, closePlaywright } from '../services/playwright.js';
|
|
7
7
|
|
|
8
8
|
function isPortAvailable(port: number): Promise<boolean> {
|
|
9
9
|
return new Promise((resolve) => {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { estimateTokenCount, truncateMessages } from '../utils/context-truncation.js';
|
|
4
|
+
|
|
5
|
+
test('estimateTokenCount: returns 0 for empty string', () => {
|
|
6
|
+
assert.strictEqual(estimateTokenCount(''), 0);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('estimateTokenCount: estimates tokens conservatively using 2.5 divisor', () => {
|
|
10
|
+
assert.strictEqual(estimateTokenCount('hello'), 2);
|
|
11
|
+
assert.strictEqual(estimateTokenCount('a'.repeat(100)), 40);
|
|
12
|
+
assert.strictEqual(estimateTokenCount('a'.repeat(250)), 100);
|
|
13
|
+
assert.strictEqual(estimateTokenCount('a'.repeat(2500)), 1000);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('estimateTokenCount: handles single character', () => {
|
|
17
|
+
assert.strictEqual(estimateTokenCount('x'), 1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('estimateTokenCount: rounds up for non-multiples of 2.5', () => {
|
|
21
|
+
assert.strictEqual(estimateTokenCount('ab'), 1);
|
|
22
|
+
assert.strictEqual(estimateTokenCount('abc'), 2);
|
|
23
|
+
assert.strictEqual(estimateTokenCount('abcd'), 2);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('truncateMessages: returns all messages when within context window', () => {
|
|
27
|
+
const messages = [
|
|
28
|
+
{ role: 'system', content: 'You are helpful.' },
|
|
29
|
+
{ role: 'user', content: 'Hello' },
|
|
30
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
31
|
+
];
|
|
32
|
+
const result = truncateMessages(messages, 100000);
|
|
33
|
+
assert.strictEqual(result.length, 3);
|
|
34
|
+
assert.strictEqual(result[0].content, 'You are helpful.');
|
|
35
|
+
assert.strictEqual(result[1].content, 'Hello');
|
|
36
|
+
assert.strictEqual(result[2].content, 'Hi there!');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('truncateMessages: preserves chronological order', () => {
|
|
40
|
+
const messages = [
|
|
41
|
+
{ role: 'user', content: 'first' },
|
|
42
|
+
{ role: 'assistant', content: 'second' },
|
|
43
|
+
{ role: 'user', content: 'third' },
|
|
44
|
+
];
|
|
45
|
+
const result = truncateMessages(messages, 100000);
|
|
46
|
+
assert.strictEqual(result[0].role, 'user');
|
|
47
|
+
assert.strictEqual(result[0].content, 'first');
|
|
48
|
+
assert.strictEqual(result[1].role, 'assistant');
|
|
49
|
+
assert.strictEqual(result[2].role, 'user');
|
|
50
|
+
assert.strictEqual(result[2].content, 'third');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('truncateMessages: drops oldest messages first when exceeding context', () => {
|
|
54
|
+
const largeContent = 'x'.repeat(5000);
|
|
55
|
+
const messages = [
|
|
56
|
+
{ role: 'user', content: largeContent },
|
|
57
|
+
{ role: 'assistant', content: largeContent },
|
|
58
|
+
{ role: 'user', content: 'latest message' },
|
|
59
|
+
];
|
|
60
|
+
const result = truncateMessages(messages, 2000);
|
|
61
|
+
const lastMsg = result[result.length - 1];
|
|
62
|
+
assert.ok(lastMsg.content.includes('latest message') || lastMsg.content.includes('[Truncated]'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('truncateMessages: returns system prompt as fallback when context is extremely small', () => {
|
|
66
|
+
const messages = [
|
|
67
|
+
{ role: 'user', content: 'some content' },
|
|
68
|
+
];
|
|
69
|
+
const systemPrompt = 'system instructions';
|
|
70
|
+
const result = truncateMessages(messages, 10, systemPrompt);
|
|
71
|
+
assert.strictEqual(result.length, 1);
|
|
72
|
+
assert.strictEqual(result[0].role, 'user');
|
|
73
|
+
assert.strictEqual(result[0].content, systemPrompt);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('truncateMessages: handles array content in messages', () => {
|
|
77
|
+
const messages = [
|
|
78
|
+
{
|
|
79
|
+
role: 'user',
|
|
80
|
+
content: [
|
|
81
|
+
{ type: 'text', text: 'hello' },
|
|
82
|
+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,...' } },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const result = truncateMessages(messages, 100000);
|
|
87
|
+
assert.strictEqual(result.length, 1);
|
|
88
|
+
assert.ok(result[0].content.includes('hello'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('truncateMessages: handles null content', () => {
|
|
92
|
+
const messages = [
|
|
93
|
+
{ role: 'user', content: null },
|
|
94
|
+
{ role: 'assistant', content: 'response' },
|
|
95
|
+
];
|
|
96
|
+
const result = truncateMessages(messages, 100000);
|
|
97
|
+
assert.strictEqual(result.length, 2);
|
|
98
|
+
assert.strictEqual(result[0].content, '');
|
|
99
|
+
assert.strictEqual(result[1].content, 'response');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('truncateMessages: handles object content', () => {
|
|
103
|
+
const messages = [
|
|
104
|
+
{ role: 'user', content: { structured: 'data', value: 42 } },
|
|
105
|
+
];
|
|
106
|
+
const result = truncateMessages(messages, 100000);
|
|
107
|
+
assert.strictEqual(result.length, 1);
|
|
108
|
+
assert.ok(result[0].content.includes('structured'));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('truncateMessages: truncates partially fitting message with marker', () => {
|
|
112
|
+
const messages = [
|
|
113
|
+
{ role: 'user', content: 'a'.repeat(10000) },
|
|
114
|
+
];
|
|
115
|
+
const result = truncateMessages(messages, 1000);
|
|
116
|
+
assert.strictEqual(result.length, 1);
|
|
117
|
+
assert.ok(
|
|
118
|
+
result[0].content.includes('[Truncated]') || result[0].content.length < 10000,
|
|
119
|
+
'Should truncate or mark as truncated'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('truncateMessages: accounts for system prompt in available tokens', () => {
|
|
124
|
+
const systemPrompt = 'x'.repeat(2000);
|
|
125
|
+
const messages = [
|
|
126
|
+
{ role: 'user', content: 'short' },
|
|
127
|
+
];
|
|
128
|
+
const withSystem = truncateMessages(messages, 2000, systemPrompt);
|
|
129
|
+
const withoutSystem = truncateMessages(messages, 2000);
|
|
130
|
+
assert.ok(withSystem.length <= withoutSystem.length);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('truncateMessages: handles empty messages array', () => {
|
|
134
|
+
const result = truncateMessages([], 100000);
|
|
135
|
+
assert.strictEqual(result.length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('truncateMessages: handles empty messages with system prompt fallback', () => {
|
|
139
|
+
const result = truncateMessages([], 5, 'fallback');
|
|
140
|
+
assert.strictEqual(result.length, 1);
|
|
141
|
+
assert.strictEqual(result[0].content, 'fallback');
|
|
142
|
+
});
|