@pedrofariasx/qwenproxy 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/account-manager.ts +57 -8
- package/src/core/accounts.ts +10 -2
- package/src/core/database.ts +12 -0
- package/src/core/model-registry.ts +29 -27
- package/src/routes/chat.ts +13 -5
- package/src/services/playwright.ts +40 -2
- package/src/services/qwen.ts +82 -12
- package/src/tests/contextTruncation.test.ts +6 -6
- package/src/tests/rotation.test.ts +64 -5
- package/src/utils/context-truncation.ts +2 -2
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { QwenAccount, loadAccounts } from './accounts.js'
|
|
1
|
+
import { QwenAccount, loadAccounts, updateAccountCooldown } from './accounts.js'
|
|
2
2
|
import { config } from './config.js'
|
|
3
3
|
|
|
4
4
|
let currentIndex = 0
|
|
@@ -21,6 +21,20 @@ function getCachedAccounts(): QwenAccount[] {
|
|
|
21
21
|
if (!accountsCache || (now - accountsCacheTimestamp) > ACCOUNTS_CACHE_TTL) {
|
|
22
22
|
accountsCache = loadAccounts()
|
|
23
23
|
accountsCacheTimestamp = now
|
|
24
|
+
|
|
25
|
+
// Sync memory cooldowns map from database values
|
|
26
|
+
for (const account of accountsCache) {
|
|
27
|
+
if (account.cooldown_until && account.cooldown_until > now) {
|
|
28
|
+
cooldowns.set(account.id, {
|
|
29
|
+
until: account.cooldown_until,
|
|
30
|
+
reason: account.cooldown_reason || 'RateLimited',
|
|
31
|
+
})
|
|
32
|
+
} else {
|
|
33
|
+
if (cooldowns.has(account.id)) {
|
|
34
|
+
cooldowns.delete(account.id)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
24
38
|
}
|
|
25
39
|
return accountsCache
|
|
26
40
|
}
|
|
@@ -31,15 +45,35 @@ export function invalidateAccountsCache(): void {
|
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
export function markAccountRateLimited(accountId: string, cooldownMs?: number, reason?: string): void {
|
|
48
|
+
const duration = cooldownMs ?? DEFAULT_COOLDOWN_MS
|
|
49
|
+
const until = Date.now() + duration
|
|
50
|
+
const cooldownReason = reason ?? 'RateLimited'
|
|
51
|
+
|
|
34
52
|
cooldowns.set(accountId, {
|
|
35
|
-
until
|
|
36
|
-
reason:
|
|
53
|
+
until,
|
|
54
|
+
reason: cooldownReason,
|
|
37
55
|
})
|
|
38
|
-
|
|
56
|
+
|
|
57
|
+
if (accountId !== 'global') {
|
|
58
|
+
try {
|
|
59
|
+
updateAccountCooldown(accountId, until, cooldownReason)
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`[AccountManager] Failed to save cooldown to DB for account ${accountId}:`, (err as Error).message)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`[AccountManager] Account ${accountId} marked as rate-limited. Cooldown until ${new Date(until).toISOString()}`)
|
|
39
66
|
}
|
|
40
67
|
|
|
41
68
|
export function clearAccountCooldown(accountId: string): void {
|
|
42
69
|
cooldowns.delete(accountId)
|
|
70
|
+
if (accountId !== 'global') {
|
|
71
|
+
try {
|
|
72
|
+
updateAccountCooldown(accountId, 0, null)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error(`[AccountManager] Failed to clear cooldown in DB for account ${accountId}:`, (err as Error).message)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
43
77
|
}
|
|
44
78
|
|
|
45
79
|
export function getAccountCooldownInfo(accountId: string): { onCooldown: boolean; remainingMs: number; reason: string } | null {
|
|
@@ -48,6 +82,13 @@ export function getAccountCooldownInfo(accountId: string): { onCooldown: boolean
|
|
|
48
82
|
const remaining = entry.until - Date.now()
|
|
49
83
|
if (remaining <= 0) {
|
|
50
84
|
cooldowns.delete(accountId)
|
|
85
|
+
if (accountId !== 'global') {
|
|
86
|
+
try {
|
|
87
|
+
updateAccountCooldown(accountId, 0, null)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`[AccountManager] Failed to clear expired cooldown in DB:`, (err as Error).message)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
51
92
|
return null
|
|
52
93
|
}
|
|
53
94
|
return { onCooldown: true, remainingMs: remaining, reason: entry.reason }
|
|
@@ -88,25 +129,33 @@ export function getNextAccount(forceReset?: boolean): QwenAccount | null {
|
|
|
88
129
|
return best
|
|
89
130
|
}
|
|
90
131
|
|
|
91
|
-
export function getNextAvailableAccount(
|
|
132
|
+
export function getNextAvailableAccount(triedAccountIds?: Set<string> | string): QwenAccount | null {
|
|
92
133
|
const accounts = getCachedAccounts()
|
|
93
134
|
if (accounts.length === 0) return null
|
|
94
135
|
|
|
136
|
+
let triedSet: Set<string>
|
|
137
|
+
if (triedAccountIds instanceof Set) {
|
|
138
|
+
triedSet = triedAccountIds
|
|
139
|
+
} else {
|
|
140
|
+
triedSet = new Set(triedAccountIds ? [triedAccountIds] : [])
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 1. Try to find an untried account that is NOT on cooldown
|
|
95
144
|
for (let i = 0; i < accounts.length; i++) {
|
|
96
145
|
const idx = (currentIndex + i) % accounts.length
|
|
97
146
|
const account = accounts[idx]
|
|
98
|
-
if (
|
|
147
|
+
if (triedSet.has(account.id)) continue
|
|
99
148
|
if (!isAccountOnCooldown(account.id)) {
|
|
100
149
|
currentIndex = (idx + 1) % accounts.length
|
|
101
150
|
return account
|
|
102
151
|
}
|
|
103
152
|
}
|
|
104
153
|
|
|
105
|
-
//
|
|
154
|
+
// 2. If all untried accounts are on cooldown, return the untried one with the shortest remaining cooldown
|
|
106
155
|
let best: QwenAccount | null = null
|
|
107
156
|
let bestRemaining = Infinity
|
|
108
157
|
for (const account of accounts) {
|
|
109
|
-
if (
|
|
158
|
+
if (triedSet.has(account.id)) continue
|
|
110
159
|
const info = getAccountCooldownInfo(account.id)
|
|
111
160
|
if (info && info.remainingMs < bestRemaining) {
|
|
112
161
|
bestRemaining = info.remainingMs
|
package/src/core/accounts.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface QwenAccount {
|
|
|
6
6
|
id: string
|
|
7
7
|
email: string
|
|
8
8
|
password: string
|
|
9
|
+
cooldown_until?: number
|
|
10
|
+
cooldown_reason?: string | null
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
let accountsCache: QwenAccount[] | null = null
|
|
@@ -16,7 +18,7 @@ function getCachedAccounts(): QwenAccount[] {
|
|
|
16
18
|
const now = Date.now()
|
|
17
19
|
if (!accountsCache || (now - accountsCacheTimestamp) > ACCOUNTS_CACHE_TTL) {
|
|
18
20
|
const db = getDatabase()
|
|
19
|
-
accountsCache = db.prepare('SELECT id, email, password FROM accounts ORDER BY created_at ASC').all() as QwenAccount[]
|
|
21
|
+
accountsCache = db.prepare('SELECT id, email, password, cooldown_until, cooldown_reason FROM accounts ORDER BY created_at ASC').all() as QwenAccount[]
|
|
20
22
|
accountsCacheTimestamp = now
|
|
21
23
|
}
|
|
22
24
|
return accountsCache
|
|
@@ -73,6 +75,12 @@ export function listAccounts(): QwenAccount[] {
|
|
|
73
75
|
|
|
74
76
|
export function getAccountCredentials(id: string): QwenAccount | undefined {
|
|
75
77
|
const db = getDatabase()
|
|
76
|
-
const row = db.prepare('SELECT id, email, password FROM accounts WHERE id = ?').get(id)
|
|
78
|
+
const row = db.prepare('SELECT id, email, password, cooldown_until, cooldown_reason FROM accounts WHERE id = ?').get(id)
|
|
77
79
|
return row as QwenAccount | undefined
|
|
78
80
|
}
|
|
81
|
+
|
|
82
|
+
export function updateAccountCooldown(id: string, cooldownUntil: number, reason: string | null): void {
|
|
83
|
+
const db = getDatabase()
|
|
84
|
+
db.prepare('UPDATE accounts SET cooldown_until = ?, cooldown_reason = ? WHERE id = ?').run(cooldownUntil, reason, id)
|
|
85
|
+
invalidateAccountsCache()
|
|
86
|
+
}
|
package/src/core/database.ts
CHANGED
|
@@ -42,6 +42,18 @@ function runMigrations(db: Database.Database): void {
|
|
|
42
42
|
|
|
43
43
|
CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);
|
|
44
44
|
`)
|
|
45
|
+
|
|
46
|
+
// Add cooldown columns if they don't exist
|
|
47
|
+
try {
|
|
48
|
+
db.exec(`ALTER TABLE accounts ADD COLUMN cooldown_until INTEGER DEFAULT 0;`)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Column already exists or error
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
db.exec(`ALTER TABLE accounts ADD COLUMN cooldown_reason TEXT;`)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Column already exists or error
|
|
56
|
+
}
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
/**
|
|
@@ -25,45 +25,47 @@ const modelContextWindows: Record<string, number> = {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const modelTokenDivisors: Record<string, number> = {
|
|
28
|
-
'qwen3.7-max':
|
|
29
|
-
'qwen3.6-max-preview':
|
|
30
|
-
'qwen3.5-max-2026-03-08':
|
|
31
|
-
'qwen3-max-2026-01-23':
|
|
32
|
-
'qwen-latest-series-invite-beta-v24':
|
|
33
|
-
'qwen3.7-plus':
|
|
34
|
-
'qwen3.6-plus':
|
|
35
|
-
'qwen3.6-plus-preview':
|
|
36
|
-
'qwen3.5-plus':
|
|
37
|
-
'qwen-plus-2025-07-28':
|
|
38
|
-
'qwen-latest-series-invite-beta-v16':
|
|
39
|
-
'qwen3.5-flash':
|
|
40
|
-
'qwen3.5-omni-plus':
|
|
41
|
-
'qwen3.5-omni-flash':
|
|
42
|
-
'qwen3-omni-flash-2025-12-01':
|
|
43
|
-
'qwen3.5-397b-a17b':
|
|
44
|
-
'qwen3.5-122b-a10b':
|
|
45
|
-
'qwen3.6-35b-a3b':
|
|
46
|
-
'qwen3.5-35b-a3b':
|
|
47
|
-
'qwen3.6-27b':
|
|
48
|
-
'qwen3.5-27b':
|
|
49
|
-
'qwen3-coder-plus':
|
|
50
|
-
'qwen3-vl-plus':
|
|
28
|
+
'qwen3.7-max': 3.5,
|
|
29
|
+
'qwen3.6-max-preview': 3.5,
|
|
30
|
+
'qwen3.5-max-2026-03-08': 3.5,
|
|
31
|
+
'qwen3-max-2026-01-23': 3.5,
|
|
32
|
+
'qwen-latest-series-invite-beta-v24': 3.5,
|
|
33
|
+
'qwen3.7-plus': 3.5,
|
|
34
|
+
'qwen3.6-plus': 3.5,
|
|
35
|
+
'qwen3.6-plus-preview': 3.5,
|
|
36
|
+
'qwen3.5-plus': 3.5,
|
|
37
|
+
'qwen-plus-2025-07-28': 3.5,
|
|
38
|
+
'qwen-latest-series-invite-beta-v16': 3.5,
|
|
39
|
+
'qwen3.5-flash': 3.2,
|
|
40
|
+
'qwen3.5-omni-plus': 3.0,
|
|
41
|
+
'qwen3.5-omni-flash': 3.0,
|
|
42
|
+
'qwen3-omni-flash-2025-12-01': 3.0,
|
|
43
|
+
'qwen3.5-397b-a17b': 3.2,
|
|
44
|
+
'qwen3.5-122b-a10b': 3.2,
|
|
45
|
+
'qwen3.6-35b-a3b': 3.2,
|
|
46
|
+
'qwen3.5-35b-a3b': 3.2,
|
|
47
|
+
'qwen3.6-27b': 3.2,
|
|
48
|
+
'qwen3.5-27b': 3.2,
|
|
49
|
+
'qwen3-coder-plus': 3.8,
|
|
50
|
+
'qwen3-vl-plus': 3.5,
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const defaultContextWindow = 131072
|
|
54
|
-
const defaultTokenDivisor =
|
|
55
|
-
export const MAX_PAYLOAD_SIZE =
|
|
54
|
+
const defaultTokenDivisor = 3.5
|
|
55
|
+
export const MAX_PAYLOAD_SIZE = 50 * 1024 * 1024
|
|
56
56
|
|
|
57
57
|
export function setModelContextWindow(modelId: string, contextWindow: number): void {
|
|
58
58
|
modelContextWindows[modelId] = contextWindow
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export function getModelContextWindow(modelId
|
|
61
|
+
export function getModelContextWindow(modelId?: string): number {
|
|
62
|
+
if (!modelId) return defaultContextWindow
|
|
62
63
|
const baseId = modelId.replace('-no-thinking', '')
|
|
63
64
|
return modelContextWindows[baseId] ?? defaultContextWindow
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
export function getModelTokenDivisor(modelId
|
|
67
|
+
export function getModelTokenDivisor(modelId?: string): number {
|
|
68
|
+
if (!modelId) return defaultTokenDivisor
|
|
67
69
|
const baseId = modelId.replace('-no-thinking', '')
|
|
68
70
|
return modelTokenDivisors[baseId] ?? defaultTokenDivisor
|
|
69
71
|
}
|
package/src/routes/chat.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { QwenStreamParser, ParsedChunkResult } from '../utils/qwen-stream-parser
|
|
|
21
21
|
import { getModelContextWindow } from '../core/model-registry.js'
|
|
22
22
|
import { truncateMessages, estimateTokenCount } from '../utils/context-truncation.js';
|
|
23
23
|
import { getNextAccount, getNextAvailableAccount, markAccountRateLimited, getAccountCooldownInfo } from '../core/account-manager.js';
|
|
24
|
+
import { loadAccounts } from '../core/accounts.js';
|
|
24
25
|
import { registerStream, removeStream, getStream } from '../core/stream-registry.js';
|
|
25
26
|
import { metrics } from '../core/metrics.js'
|
|
26
27
|
|
|
@@ -284,7 +285,7 @@ export async function chatCompletions(c: Context) {
|
|
|
284
285
|
const accountEmail = account.email;
|
|
285
286
|
|
|
286
287
|
if (triedAccountIds.has(accountId)) {
|
|
287
|
-
account = getNextAvailableAccount(
|
|
288
|
+
account = getNextAvailableAccount(triedAccountIds);
|
|
288
289
|
continue;
|
|
289
290
|
}
|
|
290
291
|
triedAccountIds.add(accountId);
|
|
@@ -292,7 +293,7 @@ export async function chatCompletions(c: Context) {
|
|
|
292
293
|
const cooldownInfo = getAccountCooldownInfo(accountId);
|
|
293
294
|
if (cooldownInfo && accountId !== 'global') {
|
|
294
295
|
console.log(`[Chat] Skipping account ${accountEmail} (${accountId}) — on cooldown for ${Math.round(cooldownInfo.remainingMs / 1000)}s (${cooldownInfo.reason})`);
|
|
295
|
-
account = getNextAvailableAccount(
|
|
296
|
+
account = getNextAvailableAccount(triedAccountIds);
|
|
296
297
|
continue;
|
|
297
298
|
}
|
|
298
299
|
|
|
@@ -329,9 +330,10 @@ export async function chatCompletions(c: Context) {
|
|
|
329
330
|
|
|
330
331
|
if (err.upstreamCode === 'RateLimited' || err.upstreamStatus === 429) {
|
|
331
332
|
const hourHint = err.message?.match(/Wait about (\d+) hour/);
|
|
332
|
-
const
|
|
333
|
+
const hours = hourHint ? parseInt(hourHint[1]) : 24;
|
|
334
|
+
const cooldownMs = hours * 60 * 60 * 1000;
|
|
333
335
|
markAccountRateLimited(accountId, cooldownMs, 'RateLimited');
|
|
334
|
-
console.warn(`[Chat] Account ${accountEmail} (${accountId}) rate-limited.
|
|
336
|
+
console.warn(`[Chat] Account ${accountEmail} (${accountId}) rate-limited. Entering cooldown for ${hours} hours.`);
|
|
335
337
|
lastError = err;
|
|
336
338
|
break;
|
|
337
339
|
}
|
|
@@ -364,11 +366,17 @@ export async function chatCompletions(c: Context) {
|
|
|
364
366
|
break;
|
|
365
367
|
}
|
|
366
368
|
|
|
367
|
-
account = getNextAvailableAccount(
|
|
369
|
+
account = getNextAvailableAccount(triedAccountIds);
|
|
368
370
|
}
|
|
369
371
|
|
|
370
372
|
if (!stream) {
|
|
371
373
|
removeStream(completionId);
|
|
374
|
+
// Check if all accounts are on cooldown
|
|
375
|
+
const accounts = loadAccounts();
|
|
376
|
+
const allOnCooldown = accounts.every(a => getAccountCooldownInfo(a.id) !== null);
|
|
377
|
+
if (allOnCooldown) {
|
|
378
|
+
console.warn(`[Chat] CRITICAL: All ${accounts.length} accounts are currently rate-limited or on cooldown!`);
|
|
379
|
+
}
|
|
372
380
|
throw lastError || new Error('All accounts failed');
|
|
373
381
|
}
|
|
374
382
|
|
|
@@ -188,9 +188,28 @@ export async function getBasicHeaders(accountId?: string): Promise<{ cookie: str
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
const cache = getAccountHeaderCache(cacheKey);
|
|
191
|
+
let bxUa = cache.currentHeaders['bx-ua'];
|
|
192
|
+
let bxUmidtoken = cache.currentHeaders['bx-umidtoken'];
|
|
191
193
|
const bxV = cache.currentHeaders['bx-v'] || '2.5.36';
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
|
|
195
|
+
// Auto-recover missing anti-fraud headers by triggering full header interception
|
|
196
|
+
if (!bxUa || !bxUmidtoken) {
|
|
197
|
+
console.log(`[Playwright] Missing bx-ua/bx-umidtoken for ${cacheKey}, triggering header interception...`);
|
|
198
|
+
try {
|
|
199
|
+
const result = await getQwenHeaders(true, accountId);
|
|
200
|
+
bxUa = result.headers['bx-ua'];
|
|
201
|
+
bxUmidtoken = result.headers['bx-umidtoken'];
|
|
202
|
+
return {
|
|
203
|
+
cookie: await getCookies(accountId),
|
|
204
|
+
userAgent,
|
|
205
|
+
bxV: result.headers['bx-v'] || bxV,
|
|
206
|
+
bxUa,
|
|
207
|
+
bxUmidtoken,
|
|
208
|
+
};
|
|
209
|
+
} catch (err: any) {
|
|
210
|
+
console.warn(`[Playwright] Failed to auto-recover headers for ${cacheKey}: ${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
194
213
|
|
|
195
214
|
return { cookie, userAgent, bxV, bxUa, bxUmidtoken };
|
|
196
215
|
}
|
|
@@ -649,6 +668,25 @@ export async function initPlaywrightForAccount(account: QwenAccount, headless =
|
|
|
649
668
|
if (!hasAuthCookie && account.email && account.password) {
|
|
650
669
|
await loginToQwenWithContext(acctContext, acctPage, account.email, account.password);
|
|
651
670
|
}
|
|
671
|
+
|
|
672
|
+
// Navigate to Qwen home to validate session and populate cookies
|
|
673
|
+
try {
|
|
674
|
+
await acctPage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
675
|
+
const url = acctPage.url();
|
|
676
|
+
if (url.includes('auth') || url.includes('login')) {
|
|
677
|
+
if (account.email && account.password) {
|
|
678
|
+
console.log(`[Playwright] Session expired for ${account.email}, re-logging in...`);
|
|
679
|
+
await loginToQwenWithContext(acctContext, acctPage, account.email, account.password);
|
|
680
|
+
await acctPage.goto('https://chat.qwen.ai/', { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
681
|
+
} else {
|
|
682
|
+
console.warn(`[Playwright] Session expired for account ${account.id} but no credentials available for re-login.`);
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
console.log(`[Playwright] Session validated for ${account.email}.`);
|
|
686
|
+
}
|
|
687
|
+
} catch (err: any) {
|
|
688
|
+
console.warn(`[Playwright] Failed to validate session for ${account.email}: ${err.message}`);
|
|
689
|
+
}
|
|
652
690
|
}
|
|
653
691
|
|
|
654
692
|
export async function launchManualLoginAccount(accountId: string, browserType: BrowserType = 'chromium'): Promise<{ context: BrowserContext, page: Page }> {
|
package/src/services/qwen.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getQwenHeaders, getBasicHeaders } from './playwright.js';
|
|
2
2
|
import { MAX_PAYLOAD_SIZE } from '../core/model-registry.js';
|
|
3
|
+
import { markAccountRateLimited } from '../core/account-manager.js';
|
|
3
4
|
import crypto from 'crypto';
|
|
4
5
|
|
|
5
6
|
const CACHED_TIMEZONE = new Date().toString().split(' (')[0];
|
|
@@ -16,9 +17,7 @@ function getClientHintsHeaders(): Record<string, string> {
|
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
return 30 + Math.floor(Math.random() * 80);
|
|
21
|
-
}
|
|
20
|
+
|
|
22
21
|
|
|
23
22
|
export class RetryableQwenStreamError extends Error {
|
|
24
23
|
readonly retryAfterMs: number;
|
|
@@ -85,7 +84,7 @@ const warmPool: Map<string, WarmPoolEntry[]> = new Map();
|
|
|
85
84
|
|
|
86
85
|
const refillPromises: Map<string, Promise<void>> = new Map();
|
|
87
86
|
|
|
88
|
-
const WARM_POOL_SIZE =
|
|
87
|
+
const WARM_POOL_SIZE = 10;
|
|
89
88
|
const WARM_POOL_TTL_MS = 10 * 60 * 1000;
|
|
90
89
|
|
|
91
90
|
function cleanupStalePool(accountId: string) {
|
|
@@ -135,8 +134,32 @@ async function createRealQwenChat(header: Record<string, string>): Promise<strin
|
|
|
135
134
|
signal: AbortSignal.timeout(30000),
|
|
136
135
|
});
|
|
137
136
|
|
|
138
|
-
if (!response.ok)
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const errText = await response.text().catch(() => '');
|
|
139
|
+
if (response.status === 429) {
|
|
140
|
+
throw new QwenUpstreamError(
|
|
141
|
+
'Qwen upstream error: RateLimited: Too many requests.',
|
|
142
|
+
'RateLimited',
|
|
143
|
+
429
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`Failed to create chat: ${response.status} - ${errText}`);
|
|
147
|
+
}
|
|
139
148
|
const json = await response.json();
|
|
149
|
+
if (json && json.success === false) {
|
|
150
|
+
const code = json.data?.code || json.code || 'UpstreamError';
|
|
151
|
+
const details = json.data?.details || json.message || 'Qwen returned an error';
|
|
152
|
+
const wait = json.data?.num !== undefined
|
|
153
|
+
? ` Wait about ${json.data.num} hour(s) before trying again.`
|
|
154
|
+
: '';
|
|
155
|
+
let status = 502;
|
|
156
|
+
if (code === 'RateLimited') status = 429;
|
|
157
|
+
throw new QwenUpstreamError(
|
|
158
|
+
`Qwen upstream error: ${code}: ${details}.${wait}`,
|
|
159
|
+
code,
|
|
160
|
+
status
|
|
161
|
+
);
|
|
162
|
+
}
|
|
140
163
|
const chatId = json.chat_id || json.id || json.data?.chat_id || json.data?.id;
|
|
141
164
|
if (!chatId) throw new Error(`Unexpected chat response: ${JSON.stringify(json).slice(0, 200)}`);
|
|
142
165
|
return chatId;
|
|
@@ -162,7 +185,15 @@ async function refillPoolForAccount(accountId: string) {
|
|
|
162
185
|
try {
|
|
163
186
|
const chatId = await createRealQwenChat(headers);
|
|
164
187
|
return { chatId, headers, accountId, timestamp: Date.now() };
|
|
165
|
-
} catch (err) {
|
|
188
|
+
} catch (err: any) {
|
|
189
|
+
if (err instanceof QwenUpstreamError) {
|
|
190
|
+
if (err.upstreamCode === 'RateLimited' || err.upstreamStatus === 429) {
|
|
191
|
+
const hourHint = err.message?.match(/Wait about (\d+) hour/);
|
|
192
|
+
const cooldownMs = hourHint ? parseInt(hourHint[1]) * 60 * 60 * 1000 : undefined;
|
|
193
|
+
markAccountRateLimited(accountId, cooldownMs, 'RateLimited');
|
|
194
|
+
console.warn(`[WarmPool] Account ${accountId} rate-limited during chat creation. Marked for cooldown.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
166
197
|
console.error(`[WarmPool] chat creation failed for ${accountId}:`, (err as Error).message);
|
|
167
198
|
return null;
|
|
168
199
|
}
|
|
@@ -187,7 +218,7 @@ export async function getWarmedChat(accountId?: string) {
|
|
|
187
218
|
}
|
|
188
219
|
if (pool.length === 0) {
|
|
189
220
|
// Retry once with short backoff if pool is still empty after first refill attempt
|
|
190
|
-
await new Promise(r => setTimeout(r,
|
|
221
|
+
await new Promise(r => setTimeout(r, 200));
|
|
191
222
|
if (!refillPromises.has(key)) {
|
|
192
223
|
refillPromises.set(key, refillPoolForAccount(key).finally(() => refillPromises.delete(key)));
|
|
193
224
|
}
|
|
@@ -489,7 +520,6 @@ export async function createQwenStream(
|
|
|
489
520
|
const url = `https://chat.qwen.ai/api/v2/chat/completions?chat_id=${chatId}`;
|
|
490
521
|
const controller = new AbortController();
|
|
491
522
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
492
|
-
await sleep(getRandomDelay());
|
|
493
523
|
const response = await fetch(url, {
|
|
494
524
|
method: 'POST',
|
|
495
525
|
headers: {
|
|
@@ -517,14 +547,13 @@ export async function createQwenStream(
|
|
|
517
547
|
clearTimeout(timeoutId);
|
|
518
548
|
|
|
519
549
|
const responseContentType = response.headers.get('content-type') || '';
|
|
520
|
-
if (response.ok && responseContentType.includes('
|
|
521
|
-
const
|
|
522
|
-
const peekText = await cloned.text().catch(() => '');
|
|
550
|
+
if (response.ok && !responseContentType.includes('text/event-stream') && response.body) {
|
|
551
|
+
const peekText = await response.clone().text().catch(() => '');
|
|
523
552
|
if (peekText.includes('FAIL_SYS_USER_VALIDATE') || peekText.includes('_____tmd_____') || peekText.includes('RGV587_ERROR')) {
|
|
524
553
|
console.warn('[Qwen] TMD challenge detected, refreshing headers and retrying...');
|
|
525
554
|
try {
|
|
526
555
|
const { headers: freshHeaders } = await getQwenHeaders(true, accountId);
|
|
527
|
-
await sleep(
|
|
556
|
+
await sleep(500 + Math.floor(Math.random() * 1000));
|
|
528
557
|
const retryController = new AbortController();
|
|
529
558
|
const retryTimeoutId = setTimeout(() => retryController.abort(), timeoutMs);
|
|
530
559
|
const retryResponse = await fetch(url, {
|
|
@@ -568,6 +597,26 @@ export async function createQwenStream(
|
|
|
568
597
|
}
|
|
569
598
|
|
|
570
599
|
if (retryResponse.ok && retryResponse.body) {
|
|
600
|
+
try {
|
|
601
|
+
const errorJson = JSON.parse(retryPeek);
|
|
602
|
+
if (errorJson && (errorJson.success === false || errorJson.error)) {
|
|
603
|
+
const code = errorJson.data?.code || errorJson.code || 'UpstreamError';
|
|
604
|
+
const details = errorJson.data?.details || errorJson.message || errorJson.error?.message || 'Qwen returned an error';
|
|
605
|
+
const wait = errorJson.data?.num !== undefined
|
|
606
|
+
? ` Wait about ${errorJson.data.num} hour(s) before trying again.`
|
|
607
|
+
: '';
|
|
608
|
+
let status = 502;
|
|
609
|
+
if (code === 'RateLimited') status = 429;
|
|
610
|
+
|
|
611
|
+
throw new QwenUpstreamError(
|
|
612
|
+
`Qwen upstream error: ${code}: ${details}.${wait}`,
|
|
613
|
+
code,
|
|
614
|
+
status,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
} catch (e) {
|
|
618
|
+
if (e instanceof QwenUpstreamError) throw e;
|
|
619
|
+
}
|
|
571
620
|
return { stream: retryResponse.body, headers: freshHeaders, uiSessionId: chatId, controller: retryController, accountId: chatEntry.accountId };
|
|
572
621
|
}
|
|
573
622
|
} catch (retryErr) {
|
|
@@ -580,6 +629,27 @@ export async function createQwenStream(
|
|
|
580
629
|
'FAIL_SYS_USER_VALIDATE',
|
|
581
630
|
403,
|
|
582
631
|
);
|
|
632
|
+
} else {
|
|
633
|
+
try {
|
|
634
|
+
const errorJson = JSON.parse(peekText);
|
|
635
|
+
if (errorJson && (errorJson.success === false || errorJson.error)) {
|
|
636
|
+
const code = errorJson.data?.code || errorJson.code || 'UpstreamError';
|
|
637
|
+
const details = errorJson.data?.details || errorJson.message || errorJson.error?.message || 'Qwen returned an error';
|
|
638
|
+
const wait = errorJson.data?.num !== undefined
|
|
639
|
+
? ` Wait about ${errorJson.data.num} hour(s) before trying again.`
|
|
640
|
+
: '';
|
|
641
|
+
let status = 502;
|
|
642
|
+
if (code === 'RateLimited') status = 429;
|
|
643
|
+
|
|
644
|
+
throw new QwenUpstreamError(
|
|
645
|
+
`Qwen upstream error: ${code}: ${details}.${wait}`,
|
|
646
|
+
code,
|
|
647
|
+
status,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
} catch (e) {
|
|
651
|
+
if (e instanceof QwenUpstreamError) throw e;
|
|
652
|
+
}
|
|
583
653
|
}
|
|
584
654
|
}
|
|
585
655
|
|
|
@@ -6,20 +6,20 @@ test('estimateTokenCount: returns 0 for empty string', () => {
|
|
|
6
6
|
assert.strictEqual(estimateTokenCount(''), 0);
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
-
test('estimateTokenCount: estimates tokens conservatively using
|
|
9
|
+
test('estimateTokenCount: estimates tokens conservatively using default divisor', () => {
|
|
10
10
|
assert.strictEqual(estimateTokenCount('hello'), 2);
|
|
11
|
-
assert.strictEqual(estimateTokenCount('a'.repeat(100)),
|
|
12
|
-
assert.strictEqual(estimateTokenCount('a'.repeat(250)),
|
|
13
|
-
assert.strictEqual(estimateTokenCount('a'.repeat(2500)),
|
|
11
|
+
assert.strictEqual(estimateTokenCount('a'.repeat(100)), 29);
|
|
12
|
+
assert.strictEqual(estimateTokenCount('a'.repeat(250)), 72);
|
|
13
|
+
assert.strictEqual(estimateTokenCount('a'.repeat(2500)), 715);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
test('estimateTokenCount: handles single character', () => {
|
|
17
17
|
assert.strictEqual(estimateTokenCount('x'), 1);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
test('estimateTokenCount: rounds up for non-multiples of
|
|
20
|
+
test('estimateTokenCount: rounds up for non-multiples of default divisor', () => {
|
|
21
21
|
assert.strictEqual(estimateTokenCount('ab'), 1);
|
|
22
|
-
assert.strictEqual(estimateTokenCount('abc'),
|
|
22
|
+
assert.strictEqual(estimateTokenCount('abc'), 1);
|
|
23
23
|
assert.strictEqual(estimateTokenCount('abcd'), 2);
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
-
import { getNextAccount, invalidateAccountsCache } from '../core/account-manager.ts';
|
|
3
|
+
import { getNextAccount, getNextAvailableAccount, markAccountRateLimited, clearAccountCooldown, invalidateAccountsCache } from '../core/account-manager.ts';
|
|
4
4
|
import { addAccount, removeAccount, loadAccounts } from '../core/accounts.ts';
|
|
5
5
|
|
|
6
6
|
test('Account Rotation: Round-Robin rotation cycle', async () => {
|
|
@@ -29,10 +29,15 @@ test('Account Rotation: Round-Robin rotation cycle', async () => {
|
|
|
29
29
|
assert.ok(third);
|
|
30
30
|
assert.ok(fourth);
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
const allAccounts = loadAccounts();
|
|
33
|
+
const firstIdx = allAccounts.findIndex(a => a.id === first.id);
|
|
34
|
+
const secondIdx = allAccounts.findIndex(a => a.id === second.id);
|
|
35
|
+
const thirdIdx = allAccounts.findIndex(a => a.id === third.id);
|
|
36
|
+
const fourthIdx = allAccounts.findIndex(a => a.id === fourth.id);
|
|
37
|
+
|
|
38
|
+
assert.strictEqual(secondIdx, (firstIdx + 1) % allAccounts.length);
|
|
39
|
+
assert.strictEqual(thirdIdx, (secondIdx + 1) % allAccounts.length);
|
|
40
|
+
assert.strictEqual(fourthIdx, (thirdIdx + 1) % allAccounts.length);
|
|
36
41
|
} finally {
|
|
37
42
|
const current = loadAccounts();
|
|
38
43
|
for (const acc of current) {
|
|
@@ -43,3 +48,57 @@ test('Account Rotation: Round-Robin rotation cycle', async () => {
|
|
|
43
48
|
invalidateAccountsCache();
|
|
44
49
|
}
|
|
45
50
|
});
|
|
51
|
+
|
|
52
|
+
test('Account Cooldown: Database persistence and recovery', async () => {
|
|
53
|
+
const email = 'cooldown-test@test.com';
|
|
54
|
+
let accountId = '';
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const newAcct = addAccount(email, 'password123');
|
|
58
|
+
accountId = newAcct.id;
|
|
59
|
+
invalidateAccountsCache();
|
|
60
|
+
|
|
61
|
+
// Mark as rate-limited with a 1-hour cooldown
|
|
62
|
+
const cooldownMs = 60 * 60 * 1000;
|
|
63
|
+
markAccountRateLimited(accountId, cooldownMs, 'RateLimited');
|
|
64
|
+
|
|
65
|
+
// Force reloading accounts from DB (simulating restart)
|
|
66
|
+
invalidateAccountsCache();
|
|
67
|
+
|
|
68
|
+
// Check if the loaded account has the cooldown synced from DB
|
|
69
|
+
const loadedAccounts = loadAccounts();
|
|
70
|
+
const target = loadedAccounts.find(a => a.id === accountId);
|
|
71
|
+
assert.ok(target);
|
|
72
|
+
assert.ok(target.cooldown_until);
|
|
73
|
+
assert.ok(target.cooldown_until > Date.now());
|
|
74
|
+
assert.strictEqual(target.cooldown_reason, 'RateLimited');
|
|
75
|
+
|
|
76
|
+
// Verify rotation skips it
|
|
77
|
+
const triedSet = new Set<string>();
|
|
78
|
+
triedSet.add('dummy-id'); // to force getNextAvailableAccount check
|
|
79
|
+
const available = getNextAvailableAccount(triedSet);
|
|
80
|
+
// Since our test account is on cooldown, if it was returned, it means no other account was available,
|
|
81
|
+
// or if we have other non-cooldown accounts, it returned one of them.
|
|
82
|
+
if (available && available.id === accountId) {
|
|
83
|
+
// If it returned our test account, it must be because all accounts are on cooldown.
|
|
84
|
+
// Let's assert that the cooldown is actually registered in memory.
|
|
85
|
+
const info = getNextAccount();
|
|
86
|
+
// It shouldn't be the first option if others are available
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Clear cooldown and verify it is updated in DB
|
|
90
|
+
clearAccountCooldown(accountId);
|
|
91
|
+
invalidateAccountsCache();
|
|
92
|
+
|
|
93
|
+
const reloaded = loadAccounts().find(a => a.id === accountId);
|
|
94
|
+
assert.ok(reloaded);
|
|
95
|
+
assert.strictEqual(reloaded.cooldown_until || 0, 0);
|
|
96
|
+
assert.strictEqual(reloaded.cooldown_reason, null);
|
|
97
|
+
|
|
98
|
+
} finally {
|
|
99
|
+
if (accountId) {
|
|
100
|
+
removeAccount(accountId);
|
|
101
|
+
}
|
|
102
|
+
invalidateAccountsCache();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getModelTokenDivisor } from '../core/model-registry.js'
|
|
2
2
|
|
|
3
3
|
export function estimateTokenCount(text: string, modelId?: string): number {
|
|
4
|
-
const divisor =
|
|
4
|
+
const divisor = getModelTokenDivisor(modelId)
|
|
5
5
|
return Math.ceil(text.length / divisor)
|
|
6
6
|
}
|
|
7
7
|
|
|
@@ -36,7 +36,7 @@ export function truncateMessages(
|
|
|
36
36
|
systemPrompt: string = '',
|
|
37
37
|
modelId?: string
|
|
38
38
|
): Array<{ role: string; content: string }> {
|
|
39
|
-
const divisor =
|
|
39
|
+
const divisor = getModelTokenDivisor(modelId)
|
|
40
40
|
const systemTokens = estimateTokenCount(systemPrompt, modelId);
|
|
41
41
|
const availableTokens = maxContextLength - systemTokens - 500;
|
|
42
42
|
|