@pedrofariasx/qwenproxy 1.2.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pedrofariasx/qwenproxy",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
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/server.ts CHANGED
@@ -70,10 +70,10 @@ app.notFound((c) => c.json({ error: 'Not found' }, 404))
70
70
  export async function startServer(): Promise<void> {
71
71
  await cache.connect()
72
72
 
73
- const { loadAccounts } = await import('../core/accounts.ts')
73
+ const { loadAccounts } = await import('../core/accounts.js')
74
74
  const accounts = loadAccounts()
75
75
 
76
- const { initPlaywright, initPlaywrightForAccount, getQwenHeaders } = await import('../services/playwright.ts')
76
+ const { initPlaywright, initPlaywrightForAccount, getQwenHeaders } = await import('../services/playwright.js')
77
77
 
78
78
  await initPlaywright(config.browser.headless)
79
79
 
@@ -87,7 +87,7 @@ export async function startServer(): Promise<void> {
87
87
  )
88
88
  )
89
89
  console.log('[Server] Pre-fetching headers for all accounts in background...')
90
- const { warmAllPools } = await import('../services/qwen.ts')
90
+ const { warmAllPools } = await import('../services/qwen.js')
91
91
  warmAllPools(accounts.map(a => a.id)).catch(() => {})
92
92
  }
93
93
 
@@ -111,7 +111,7 @@ export async function startServer(): Promise<void> {
111
111
  await cache.close()
112
112
  const { closePlaywright } = await import('../services/playwright.js')
113
113
  await closePlaywright()
114
- const { closeDatabase } = await import('../core/database.ts')
114
+ const { closeDatabase } = await import('../core/database.js')
115
115
  closeDatabase()
116
116
  server?.close()
117
117
  process.exit(0)
@@ -21,6 +21,7 @@ export class MemoryCache {
21
21
  private cleanupInterval: NodeJS.Timeout | null
22
22
  private maxEntries: number
23
23
  private totalBytes: number
24
+ private scanRegexCache: Map<string, RegExp>
24
25
 
25
26
  constructor(options?: { prefix?: string; defaultTTL?: number; maxEntries?: number }) {
26
27
  this.prefix = options?.prefix || 'qwenproxy:'
@@ -29,12 +30,14 @@ export class MemoryCache {
29
30
  this.store = new Map()
30
31
  this.totalBytes = 0
31
32
  this.cleanupInterval = null
33
+ this.scanRegexCache = new Map()
32
34
 
33
35
  this.startCleanup()
34
36
  }
35
37
 
36
38
  private entryByteSize(key: string, value: any): number {
37
- return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(value))
39
+ const valueStr = typeof value === 'string' ? value : JSON.stringify(value)
40
+ return Buffer.byteLength(key) + Buffer.byteLength(valueStr || '')
38
41
  }
39
42
 
40
43
  private evictLRU(): void {
@@ -63,11 +66,9 @@ export class MemoryCache {
63
66
  }
64
67
 
65
68
  async set<T>(key: CacheKey, value: T, ttl?: number): Promise<void> {
66
- const serialized = JSON.stringify(value)
67
- const valueBytes = Buffer.byteLength(serialized)
68
69
  const effectiveTTL = ttl || this.defaultTTL
69
70
  const fullKey = this.prefix + key
70
- const entrySize = Buffer.byteLength(fullKey) + valueBytes
71
+ const entrySize = this.entryByteSize(fullKey, value)
71
72
 
72
73
  if (this.store.has(fullKey)) {
73
74
  const oldEntry = this.store.get(fullKey)
@@ -85,7 +86,7 @@ export class MemoryCache {
85
86
  this.totalBytes += entrySize
86
87
 
87
88
  metrics.increment('cache.set')
88
- metrics.histogram('cache.value.size', valueBytes)
89
+ metrics.histogram('cache.value.size', entrySize)
89
90
  }
90
91
 
91
92
  async get<T>(key: CacheKey): Promise<T | null> {
@@ -1,4 +1,4 @@
1
- import { QwenAccount, loadAccounts } from './accounts.ts'
1
+ import { QwenAccount, loadAccounts } from './accounts.js'
2
2
  import { config } from './config.js'
3
3
 
4
4
  let currentIndex = 0
@@ -1,5 +1,5 @@
1
1
  import crypto from 'crypto'
2
- import { getDatabase } from './database.ts'
2
+ import { getDatabase } from './database.js'
3
3
  import { config } from './config.js'
4
4
 
5
5
  export interface QwenAccount {
package/src/login.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { addAccount, removeAccount, listAccounts, getAccountCredentials, QwenAccount } from './core/accounts.ts'
2
- import { initPlaywrightForAccount, closePlaywrightForAccount, BrowserType, launchManualLoginAccount, extractAccountInfoFromContext } from './services/playwright.ts'
1
+ import { addAccount, removeAccount, listAccounts, getAccountCredentials, QwenAccount } from './core/accounts.js'
2
+ import { initPlaywrightForAccount, closePlaywrightForAccount, BrowserType, launchManualLoginAccount, extractAccountInfoFromContext } from './services/playwright.js'
3
3
  import * as readline from 'readline'
4
4
  import * as dotenv from 'dotenv'
5
5
 
@@ -11,18 +11,17 @@
11
11
  import { Context } from 'hono';
12
12
  import { stream as honoStream } from 'hono/streaming';
13
13
  import crypto from 'crypto';
14
- import { createQwenStream, updateSessionParent } from '../services/qwen.ts';
15
- import { OpenAIRequest, ChoiceDelta, Message } from '../utils/types.ts';
16
- import { registry } from '../tools/registry.ts';
17
- import type { FunctionToolDefinition } from '../tools/types.ts';
18
- import { robustParseJSON } from '../utils/json.ts';
19
- import { StreamingToolParser } from '../tools/parser.ts';
20
- import { QwenStreamParser, ParsedChunkResult } from '../utils/qwen-stream-parser.ts';
21
- import { RetryableQwenStreamError } from '../services/qwen.ts';
14
+ import { createQwenStream, updateSessionParent, RetryableQwenStreamError } from '../services/qwen.js';
15
+ import { OpenAIRequest, ChoiceDelta, Message } from '../utils/types.js';
16
+ import { registry } from '../tools/registry.js';
17
+ import type { FunctionToolDefinition } from '../tools/types.js';
18
+ import { robustParseJSON } from '../utils/json.js';
19
+ import { StreamingToolParser } from '../tools/parser.js';
20
+ import { QwenStreamParser, ParsedChunkResult } from '../utils/qwen-stream-parser.js';
22
21
  import { getModelContextWindow } from '../core/model-registry.js'
23
- import { truncateMessages, estimateTokenCount } from '../utils/context-truncation.ts';
24
- import { getNextAccount, getNextAvailableAccount, markAccountRateLimited, getAccountCooldownInfo } from '../core/account-manager.ts';
25
- import { registerStream, removeStream, getStream } from '../core/stream-registry.ts';
22
+ import { truncateMessages, estimateTokenCount } from '../utils/context-truncation.js';
23
+ import { getNextAccount, getNextAvailableAccount, markAccountRateLimited, getAccountCooldownInfo } from '../core/account-manager.js';
24
+ import { registerStream, removeStream, getStream } from '../core/stream-registry.js';
26
25
  import { metrics } from '../core/metrics.js'
27
26
 
28
27
  export interface DeltaResult {
@@ -289,7 +288,7 @@ export async function chatCompletions(c: Context) {
289
288
 
290
289
  // Account selection with fallback on rate-limit/failure
291
290
  let account = getNextAccount();
292
- let triedAccountIds = new Set<string>();
291
+ const triedAccountIds = new Set<string>();
293
292
  let lastError: any = null;
294
293
 
295
294
  let stream: ReadableStream | undefined;
@@ -395,7 +394,6 @@ export async function chatCompletions(c: Context) {
395
394
 
396
395
  const toolCallsOut: any[] = [];
397
396
  let buffer = '';
398
- const hasTools = Array.isArray(bodyAny.tools) && bodyAny.tools.length > 0;
399
397
 
400
398
  const qwenParser = new QwenStreamParser(uiSessionId, {
401
399
  tools: hasTools ? bodyAny.tools : [],
@@ -524,25 +522,13 @@ export async function chatCompletions(c: Context) {
524
522
  const createdTimestamp = Math.floor(Date.now() / 1000);
525
523
 
526
524
  const fastWriteContent = (content: string) => {
527
- const chunk = JSON.stringify({
528
- id: completionId,
529
- object: 'chat.completion.chunk',
530
- created: createdTimestamp,
531
- model: body.model,
532
- choices: [makeChoice({ content })]
533
- });
534
- streamWriter.write(`data: ${chunk}\n\n`);
525
+ const escaped = content.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
526
+ streamWriter.write(`data: {"id":"${completionId}","object":"chat.completion.chunk","created":${createdTimestamp},"model":"${body.model}","choices":[{"index":0,"delta":{"content":"${escaped}"},"logprobs":null,"finish_reason":null}]}\n\n`);
535
527
  };
536
528
 
537
529
  const fastWriteReasoning = (content: string) => {
538
- const chunk = JSON.stringify({
539
- id: completionId,
540
- object: 'chat.completion.chunk',
541
- created: createdTimestamp,
542
- model: body.model,
543
- choices: [makeChoice({ reasoning_content: content })]
544
- });
545
- streamWriter.write(`data: ${chunk}\n\n`);
530
+ const escaped = content.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
531
+ streamWriter.write(`data: {"id":"${completionId}","object":"chat.completion.chunk","created":${createdTimestamp},"model":"${body.model}","choices":[{"index":0,"delta":{"reasoning_content":"${escaped}"},"logprobs":null,"finish_reason":null}]}\n\n`);
546
532
  };
547
533
 
548
534
  writeEvent({
@@ -563,7 +549,6 @@ export async function chatCompletions(c: Context) {
563
549
  let targetResponseId: string | null = null;
564
550
  let targetResponseIdSet = false;
565
551
  let currentThoughtIndex = 0;
566
- const hasTools = Array.isArray(bodyAny.tools) && bodyAny.tools.length > 0;
567
552
  const toolParser = hasTools ? new StreamingToolParser(bodyAny.tools) : null;
568
553
 
569
554
  let buffer = '';
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Context } from "hono";
8
- import { getQwenHeaders } from "../services/playwright.ts";
8
+ import { getQwenHeaders } from "../services/playwright.js";
9
9
  import crypto from "crypto";
10
10
 
11
11
  interface STSResponse {
@@ -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.ts';
15
- import { config } from '../core/config.ts';
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>();
@@ -120,9 +136,14 @@ export async function getBasicHeaders(accountId?: string): Promise<{ cookie: str
120
136
  if (!page) throw new Error('Playwright not initialized');
121
137
 
122
138
  const cookie = await getCookies(accountId);
123
- const userAgent = await page.evaluate(() => navigator.userAgent);
124
-
125
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
+
126
147
  const cache = getAccountHeaderCache(cacheKey);
127
148
  const bxV = cache.currentHeaders['bx-v'] || '2.5.36';
128
149
  const bxUa = cache.currentHeaders['bx-ua'];
@@ -138,34 +159,11 @@ export async function initPlaywright(headless = true, browserType: BrowserType =
138
159
  }
139
160
 
140
161
  const profilePath = path.resolve('qwen_profiles', '_default');
141
-
142
- let browserEngine;
143
- let channel: string | undefined;
144
-
145
- switch (browserType) {
146
- case 'firefox':
147
- browserEngine = firefox;
148
- break;
149
- case 'webkit':
150
- browserEngine = webkit;
151
- break;
152
- case 'chrome':
153
- browserEngine = chromium;
154
- channel = 'chrome';
155
- break;
156
- case 'edge':
157
- browserEngine = chromium;
158
- channel = 'msedge';
159
- break;
160
- case 'chromium':
161
- default:
162
- browserEngine = chromium;
163
- break;
164
- }
162
+ const { engine, channel } = resolveBrowserEngine(browserType);
165
163
 
166
164
  console.log(`[Playwright] Launching ${browserType}...`);
167
165
 
168
- context = await browserEngine.launchPersistentContext(profilePath, {
166
+ context = await engine.launchPersistentContext(profilePath, {
169
167
  headless,
170
168
  channel,
171
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',
@@ -249,45 +247,8 @@ export async function closePlaywright() {
249
247
 
250
248
  export async function loginToQwen(email: string, password: string): Promise<boolean> {
251
249
  if (!activePage) throw new Error('Playwright not initialized');
252
-
253
250
  console.log(`[Playwright] Attempting API login for ${email}...`);
254
-
255
- await activePage.goto('https://chat.qwen.ai/auth', { waitUntil: 'domcontentloaded' });
256
-
257
- const hashedPassword = crypto.createHash('sha256').update(password).digest('hex');
258
-
259
- const result = await activePage.evaluate(async ({ email, password }) => {
260
- try {
261
- const response = await fetch("https://chat.qwen.ai/api/v2/auths/signin", {
262
- method: "POST",
263
- headers: {
264
- "accept": "application/json, text/plain, */*",
265
- "content-type": "application/json",
266
- "source": "web",
267
- "timezone": new Date().toString().split(' (')[0],
268
- "x-request-id": crypto.randomUUID()
269
- },
270
- body: JSON.stringify({ email, password, login_type: "email" })
271
- });
272
- const data = await response.json();
273
- return { ok: response.ok, data };
274
- } catch (e: any) {
275
- return { ok: false, error: e.message };
276
- }
277
- }, { email, password: hashedPassword });
278
-
279
- if (result.ok) {
280
- console.log('[Playwright] API login request successful.');
281
- await activePage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded' });
282
- const isLogged = !(activePage.url().includes('auth') || activePage.url().includes('login'));
283
- if (isLogged) {
284
- console.log('[Playwright] Login confirmed.');
285
- return true;
286
- }
287
- }
288
-
289
- console.error('[Playwright] Login failed:', result.data || result.error);
290
- return false;
251
+ return loginToQwenWithContext(activePage.context(), activePage, email, password);
291
252
  }
292
253
 
293
254
  async function loginToQwenUI(email: string, password: string): Promise<boolean> {
@@ -374,7 +335,11 @@ async function tryLightweightCookieRefresh(accountId?: string): Promise<{ header
374
335
  try {
375
336
  const cookies = await page.context().cookies();
376
337
  const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
377
- const userAgent = await page.evaluate(() => navigator.userAgent);
338
+ let userAgent = cachedUserAgents.get(cacheKey);
339
+ if (!userAgent) {
340
+ userAgent = await page.evaluate(() => navigator.userAgent);
341
+ cachedUserAgents.set(cacheKey, userAgent);
342
+ }
378
343
 
379
344
  const now = Date.now();
380
345
  cookieCaches.set(cacheKey, { cookie: cookieStr, timestamp: now });
@@ -473,7 +438,7 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
473
438
  console.warn('[Playwright] Detected login page but QWEN_EMAIL/PASSWORD not provided in .env');
474
439
  }
475
440
  } else {
476
- const { getAccountCredentials } = await import('../core/accounts.ts');
441
+ const { getAccountCredentials } = await import('../core/accounts.js');
477
442
  const creds = getAccountCredentials(accountId);
478
443
  if (creds && creds.email && creds.password) {
479
444
  console.log(`[Playwright] Detected login page for account ${creds.email}. Attempting login...`);
@@ -548,7 +513,7 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
548
513
  cache.lastHeadersTime = Date.now();
549
514
  cache.refreshInProgress = false;
550
515
 
551
- import('./qwen.ts').then(m => m.disableNativeTools(accountId).catch(() => {}));
516
+ import('./qwen.js').then(m => m.disableNativeTools(accountId).catch(() => {}));
552
517
 
553
518
  await route.abort('aborted');
554
519
 
@@ -609,34 +574,11 @@ async function _getQwenHeadersInternal(forceNew = false, accountId?: string): Pr
609
574
 
610
575
  export async function initPlaywrightForAccount(account: QwenAccount, headless = true, browserType: BrowserType = 'chromium') {
611
576
  const profilePath = path.resolve('qwen_profiles', account.id);
612
-
613
- let browserEngine;
614
- let channel: string | undefined;
615
-
616
- switch (browserType) {
617
- case 'firefox':
618
- browserEngine = firefox;
619
- break;
620
- case 'webkit':
621
- browserEngine = webkit;
622
- break;
623
- case 'chrome':
624
- browserEngine = chromium;
625
- channel = 'chrome';
626
- break;
627
- case 'edge':
628
- browserEngine = chromium;
629
- channel = 'msedge';
630
- break;
631
- case 'chromium':
632
- default:
633
- browserEngine = chromium;
634
- break;
635
- }
577
+ const { engine, channel } = resolveBrowserEngine(browserType);
636
578
 
637
579
  console.log(`[Playwright] Launching ${browserType} for account ${account.email}...`);
638
580
 
639
- const acctContext = await browserEngine.launchPersistentContext(profilePath, {
581
+ const acctContext = await engine.launchPersistentContext(profilePath, {
640
582
  headless,
641
583
  channel,
642
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',
@@ -666,32 +608,9 @@ export async function initPlaywrightForAccount(account: QwenAccount, headless =
666
608
 
667
609
  export async function launchManualLoginAccount(accountId: string, browserType: BrowserType = 'chromium'): Promise<{ context: BrowserContext, page: Page }> {
668
610
  const profilePath = path.resolve('qwen_profiles', accountId);
669
-
670
- let browserEngine;
671
- let channel: string | undefined;
672
-
673
- switch (browserType) {
674
- case 'firefox':
675
- browserEngine = firefox;
676
- break;
677
- case 'webkit':
678
- browserEngine = webkit;
679
- break;
680
- case 'chrome':
681
- browserEngine = chromium;
682
- channel = 'chrome';
683
- break;
684
- case 'edge':
685
- browserEngine = chromium;
686
- channel = 'msedge';
687
- break;
688
- case 'chromium':
689
- default:
690
- browserEngine = chromium;
691
- break;
692
- }
611
+ const { engine, channel } = resolveBrowserEngine(browserType);
693
612
 
694
- const acctContext = await browserEngine.launchPersistentContext(profilePath, {
613
+ const acctContext = await engine.launchPersistentContext(profilePath, {
695
614
  headless: false,
696
615
  channel,
697
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',
@@ -1,4 +1,4 @@
1
- import { getQwenHeaders, getBasicHeaders } from './playwright.ts';
1
+ import { getQwenHeaders, getBasicHeaders } from './playwright.js';
2
2
  import crypto from 'crypto';
3
3
 
4
4
  const CACHED_TIMEZONE = new Date().toString().split(' (')[0];
@@ -28,8 +28,7 @@ interface SessionEntry {
28
28
  timestamp: number;
29
29
  }
30
30
 
31
- const sessionStates: Map<string, SessionEntry> = (globalThis as any)._sessionStates || new Map();
32
- (globalThis as any)._sessionStates = sessionStates;
31
+ const sessionStates: Map<string, SessionEntry> = new Map();
33
32
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
34
33
 
35
34
  function cleanupStaleSessions() {
@@ -65,11 +64,9 @@ interface WarmPoolEntry {
65
64
  timestamp: number;
66
65
  }
67
66
 
68
- const warmPool: Map<string, WarmPoolEntry[]> = (globalThis as any)._warmPool || new Map();
69
- (globalThis as any)._warmPool = warmPool;
67
+ const warmPool: Map<string, WarmPoolEntry[]> = new Map();
70
68
 
71
- const refillPromises: Map<string, Promise<void>> = (globalThis as any)._refillPromises || new Map();
72
- (globalThis as any)._refillPromises = refillPromises;
69
+ const refillPromises: Map<string, Promise<void>> = new Map();
73
70
 
74
71
  const WARM_POOL_SIZE = 5;
75
72
  const WARM_POOL_TTL_MS = 10 * 60 * 1000;
@@ -78,9 +75,8 @@ function cleanupStalePool(accountId: string) {
78
75
  const pool = warmPool.get(accountId);
79
76
  if (!pool) return;
80
77
  const now = Date.now();
81
- for (let i = pool.length - 1; i >= 0; i--) {
82
- if (now - pool[i].timestamp > WARM_POOL_TTL_MS) pool.splice(i, 1);
83
- }
78
+ const filtered = pool.filter(e => now - e.timestamp <= WARM_POOL_TTL_MS);
79
+ if (filtered.length !== pool.length) warmPool.set(accountId, filtered);
84
80
  }
85
81
 
86
82
  async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, string>> {
@@ -93,8 +89,6 @@ async function getBasicQwenHeaders(accountId?: string): Promise<Record<string, s
93
89
  }
94
90
 
95
91
  async function createRealQwenChat(header: Record<string, string>): Promise<string> {
96
- const controller = new AbortController();
97
- const timeoutId = setTimeout(() => controller.abort(), 30000);
98
92
  const response = await fetch('https://chat.qwen.ai/api/v2/chats/new', {
99
93
  method: 'POST',
100
94
  headers: {
@@ -116,9 +110,8 @@ async function createRealQwenChat(header: Record<string, string>): Promise<strin
116
110
  timestamp: Date.now(),
117
111
  project_id: '',
118
112
  }),
119
- signal: controller.signal,
113
+ signal: AbortSignal.timeout(30000),
120
114
  });
121
- clearTimeout(timeoutId);
122
115
 
123
116
  if (!response.ok) throw new Error(`Failed to create chat: ${response.status}`);
124
117
  const json = await response.json();
@@ -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.ts';
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.ts';
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
+ });