qwenproxy-cli 1.6.11 → 1.6.12

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.
@@ -78,10 +78,15 @@ import {
78
78
  type FingerprintProfile,
79
79
  } from "./fingerprint.ts";
80
80
  import { subtlePageActivity } from "./human-behavior.ts";
81
- import { solveBaxiaCaptcha } from "./captcha-solver.ts";
81
+ import { solveVisibleCaptcha } from "./captcha-solver.ts";
82
82
  import { qwenOrigin, qwenUrl } from "./qwen-url.ts";
83
83
  import { setWafContextResetListener } from "../core/waf-isolation.ts";
84
- import { updateQwenWebVersion, getQwenWebVersion } from "./qwen-headers.ts";
84
+ import {
85
+ getQwenBxVersion,
86
+ getQwenWebVersion,
87
+ updateQwenBxVersion,
88
+ updateQwenWebVersion,
89
+ } from "./qwen-headers.ts";
85
90
  import { getAccountProfilePath, getProfilesDir } from "../core/paths.ts";
86
91
  import { parseJwtExpiry, isTokenExpiringSoon } from "../utils/jwt.ts";
87
92
  import { formatAsciiTimezone } from "../utils/timezone.ts";
@@ -598,13 +603,14 @@ const CHAT_INPUT_ACTION_TIMEOUT_MS = 10_000;
598
603
  */
599
604
  async function clearVisibleChallenge(page: Page): Promise<void> {
600
605
  if (page.isClosed()) return;
601
- // 1. Solve slider puzzle captcha if present
606
+ // 1. Solve a slider/jigsaw challenge if present (Baxia NC or Aliyun WAF)
602
607
  if (config.captcha.enabled) {
603
- await solveBaxiaCaptcha(page, {
608
+ await solveVisibleCaptcha(page, {
604
609
  waitForMs: 0,
605
610
  maxAttempts: config.captcha.maxAttempts,
606
611
  retryDelayMs: config.captcha.retryDelayMs,
607
612
  settleMs: config.captcha.settleMs,
613
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
608
614
  }).catch(() => false);
609
615
  }
610
616
 
@@ -1307,13 +1313,22 @@ export async function getBasicHeaders(accountId: string): Promise<{
1307
1313
  mobile = data.mobile ? "?1" : "?0";
1308
1314
  }
1309
1315
  let bundleVersion: string | null = null;
1316
+ let baxiaVersion: string | null = null;
1310
1317
  try {
1311
1318
  const el = document.querySelector('link[href*="qwen-chat-fe"], script[src*="qwen-chat-fe"], img[src*="qwen-chat-fe"]');
1312
1319
  const src = el ? (el.getAttribute("href") || el.getAttribute("src") || "") : "";
1313
1320
  const match = src.match(/qwen-chat-fe\/(\d+\.\d+\.\d+)/) || document.documentElement.innerHTML.match(/qwen-chat-fe\/(\d+\.\d+\.\d+)/);
1314
1321
  if (match) bundleVersion = match[1];
1315
1322
  } catch {}
1316
- return { ua, secChUa, platform, mobile, bundleVersion };
1323
+ try {
1324
+ const baxia = (window as unknown as {
1325
+ baxiaCommon?: { version?: unknown };
1326
+ }).baxiaCommon;
1327
+ if (typeof baxia?.version === "string") {
1328
+ baxiaVersion = baxia.version;
1329
+ }
1330
+ } catch {}
1331
+ return { ua, secChUa, platform, mobile, bundleVersion, baxiaVersion };
1317
1332
  }),
1318
1333
  config.timeouts.page,
1319
1334
  `User-agent lookup timed out for ${accountId}`,
@@ -1322,6 +1337,9 @@ export async function getBasicHeaders(accountId: string): Promise<{
1322
1337
  if (nav.bundleVersion) {
1323
1338
  updateQwenWebVersion(nav.bundleVersion);
1324
1339
  }
1340
+ if (nav.baxiaVersion) {
1341
+ updateQwenBxVersion(nav.baxiaVersion);
1342
+ }
1325
1343
  cachedUserAgents.set(accountId, userAgent);
1326
1344
  const hints = getHeaderCache(accountId).headers;
1327
1345
  clientHints = {
@@ -1359,7 +1377,7 @@ export async function getBasicHeaders(accountId: string): Promise<{
1359
1377
  markAccountHeadersReady(accountId);
1360
1378
  const bxUa = cache.headers["bx-ua"];
1361
1379
  const bxUmidtoken = cache.headers["bx-umidtoken"];
1362
- const bxV = cache.headers["bx-v"] || "2.5.37";
1380
+ const bxV = cache.headers["bx-v"] || getQwenBxVersion();
1363
1381
  const cookie = await getCookies(accountId);
1364
1382
  return { cookie, userAgent, bxV, bxUa, bxUmidtoken, ...clientHints };
1365
1383
  }
@@ -1383,7 +1401,7 @@ export async function getBasicHeaders(accountId: string): Promise<{
1383
1401
  cookieCaches.set(accountId, { cookie, timestamp: Date.now() });
1384
1402
  const bxUa = cache.headers["bx-ua"];
1385
1403
  const bxUmidtoken = cache.headers["bx-umidtoken"];
1386
- const bxV = cache.headers["bx-v"] || "2.5.37";
1404
+ const bxV = cache.headers["bx-v"] || getQwenBxVersion();
1387
1405
  cache.lastRefresh = Date.now();
1388
1406
  markAccountHeadersReady(accountId);
1389
1407
  console.log(
@@ -1465,6 +1483,9 @@ export async function getBasicHeaders(accountId: string): Promise<{
1465
1483
  if (persisted.version) {
1466
1484
  updateQwenWebVersion(persisted.version);
1467
1485
  }
1486
+ if (persisted.bxV) {
1487
+ updateQwenBxVersion(persisted.bxV);
1488
+ }
1468
1489
  cache.lastRefresh = persisted.capturedAt;
1469
1490
  markAccountHeadersReady(accountId);
1470
1491
  }
@@ -1494,7 +1515,7 @@ export async function getBasicHeaders(accountId: string): Promise<{
1494
1515
  markAccountHeadersReady(accountId);
1495
1516
  const bxUa = cache.headers["bx-ua"];
1496
1517
  const bxUmidtoken = cache.headers["bx-umidtoken"];
1497
- const bxV = cache.headers["bx-v"] || "2.5.37";
1518
+ const bxV = cache.headers["bx-v"] || getQwenBxVersion();
1498
1519
 
1499
1520
  // Read cookie AFTER all refreshes (re-login may have updated it)
1500
1521
  cookieCaches.delete(accountId);
@@ -1786,6 +1807,9 @@ export async function initPlaywrightForAccount(
1786
1807
  if (persisted.version) {
1787
1808
  updateQwenWebVersion(persisted.version);
1788
1809
  }
1810
+ if (persisted.bxV) {
1811
+ updateQwenBxVersion(persisted.bxV);
1812
+ }
1789
1813
  cache.lastRefresh = persisted.capturedAt;
1790
1814
  markAccountHeadersReady(account.id);
1791
1815
  restoredFromDb = true;
@@ -2239,7 +2263,7 @@ export async function startEmailOtpLogin(
2239
2263
  {
2240
2264
  email: normalizedEmail,
2241
2265
  requestId,
2242
- version: getQwenWebVersion() || "0.3.26",
2266
+ version: getQwenWebVersion(),
2243
2267
  timezone: formatAsciiTimezone(),
2244
2268
  },
2245
2269
  ).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
@@ -2351,7 +2375,7 @@ export async function verifyEmailOtpLogin(
2351
2375
  email: session.email,
2352
2376
  code: cleanCode,
2353
2377
  requestId,
2354
- version: getQwenWebVersion() || "0.3.26",
2378
+ version: getQwenWebVersion(),
2355
2379
  timezone: formatAsciiTimezone(),
2356
2380
  },
2357
2381
  ).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
@@ -2408,8 +2432,8 @@ export async function verifyEmailOtpLogin(
2408
2432
  saveAuthSession(session.accountId, {
2409
2433
  cookie: `token=${accessToken}`,
2410
2434
  userAgent: session.fingerprint.userAgent,
2411
- bxV: "2.5.37",
2412
- version: getQwenWebVersion() || "0.3.26",
2435
+ bxV: getQwenBxVersion(),
2436
+ version: getQwenWebVersion(),
2413
2437
  userId: userId || undefined,
2414
2438
  tokenExpiresAt: exp || undefined,
2415
2439
  refreshToken: refreshToken || undefined,
@@ -2565,7 +2589,7 @@ async function loginToQwen(
2565
2589
  saveAuthSession(accountId, {
2566
2590
  cookie: cookieStr,
2567
2591
  userAgent: cache.headers["user-agent"] || "",
2568
- bxV: cache.headers["bx-v"] || "2.5.37",
2592
+ bxV: cache.headers["bx-v"] || getQwenBxVersion(),
2569
2593
  bxUa: cache.headers["bx-ua"] || "",
2570
2594
  bxUmidtoken: cache.headers["bx-umidtoken"] || "",
2571
2595
  secChUa: cache.headers["sec-ch-ua"] || undefined,
@@ -2641,7 +2665,7 @@ async function loginToQwen(
2641
2665
  saveAuthSession(accountId, {
2642
2666
  cookie: cookieStr,
2643
2667
  userAgent: cache.headers["user-agent"] || "",
2644
- bxV: cache.headers["bx-v"] || "2.5.37",
2668
+ bxV: cache.headers["bx-v"] || getQwenBxVersion(),
2645
2669
  bxUa: cache.headers["bx-ua"] || "",
2646
2670
  bxUmidtoken: cache.headers["bx-umidtoken"] || "",
2647
2671
  secChUa: cache.headers["sec-ch-ua"] || undefined,
@@ -2726,7 +2750,7 @@ async function loginViaApi(
2726
2750
 
2727
2751
  const signinUrl = "https://auth.qwen.ai/api/v2/auths/signin";
2728
2752
  const requestId = crypto.randomUUID();
2729
- const webVersion = getQwenWebVersion() || "0.3.26";
2753
+ const webVersion = getQwenWebVersion();
2730
2754
 
2731
2755
  const evalRes = await page
2732
2756
  .evaluate(
@@ -2935,6 +2959,18 @@ async function loginViaUi(
2935
2959
  return { success: true };
2936
2960
  }
2937
2961
 
2962
+ // A WAF jigsaw can render before the form is usable. Clear it first so the
2963
+ // email/password fields are not covered and the submit is not rejected.
2964
+ if (config.captcha.enabled) {
2965
+ await solveVisibleCaptcha(page, {
2966
+ waitForMs: 1_500,
2967
+ maxAttempts: config.captcha.maxAttempts,
2968
+ retryDelayMs: config.captcha.retryDelayMs,
2969
+ settleMs: config.captcha.settleMs,
2970
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
2971
+ }).catch(() => false);
2972
+ }
2973
+
2938
2974
  // Wait for email input
2939
2975
  const emailSelector = [
2940
2976
  'input[type="email"]',
@@ -3129,12 +3165,13 @@ async function loginViaUi(
3129
3165
  while (Date.now() < postSubmitDeadline) {
3130
3166
  if (page.isClosed()) break;
3131
3167
 
3132
- // If a slider puzzle captcha appeared after submit, solve it automatically
3168
+ // If a slider/jigsaw challenge appeared after submit, solve it automatically
3133
3169
  if (config.captcha.enabled) {
3134
- await solveBaxiaCaptcha(page, {
3170
+ await solveVisibleCaptcha(page, {
3135
3171
  waitForMs: 1500,
3136
3172
  maxAttempts: config.captcha.maxAttempts,
3137
3173
  retryDelayMs: config.captcha.retryDelayMs,
3174
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
3138
3175
  }).catch(() => {});
3139
3176
  }
3140
3177
 
@@ -3281,7 +3318,7 @@ export async function refreshAccountToken(
3281
3318
  // 2. If we have a refresh token, invoke the working refresh endpoint in-page
3282
3319
  if (refreshToken) {
3283
3320
  try {
3284
- const webVersion = getQwenWebVersion() || "0.3.26";
3321
+ const webVersion = getQwenWebVersion();
3285
3322
  const refreshResult = await page.evaluate(
3286
3323
  async ({ rTok, tz, version }) => {
3287
3324
  try {
@@ -3354,7 +3391,7 @@ export async function refreshAccountToken(
3354
3391
  saveAuthSession(accountId, {
3355
3392
  cookie: cookieStr,
3356
3393
  userAgent: cache.headers["user-agent"] || session?.userAgent || "",
3357
- bxV: cache.headers["bx-v"] || session?.bxV || "2.5.37",
3394
+ bxV: cache.headers["bx-v"] || session?.bxV || getQwenBxVersion(),
3358
3395
  bxUa: cache.headers["bx-ua"] || session?.bxUa || "",
3359
3396
  bxUmidtoken: cache.headers["bx-umidtoken"] || session?.bxUmidtoken || "",
3360
3397
  secChUa: cache.headers["sec-ch-ua"] || session?.secChUa,
@@ -3391,7 +3428,7 @@ export async function refreshAccountToken(
3391
3428
  saveAuthSession(accountId, {
3392
3429
  cookie: cookieStr,
3393
3430
  userAgent: cache.headers["user-agent"] || session?.userAgent || "",
3394
- bxV: cache.headers["bx-v"] || session?.bxV || "2.5.37",
3431
+ bxV: cache.headers["bx-v"] || session?.bxV || getQwenBxVersion(),
3395
3432
  bxUa: cache.headers["bx-ua"] || session?.bxUa || "",
3396
3433
  bxUmidtoken: cache.headers["bx-umidtoken"] || session?.bxUmidtoken || "",
3397
3434
  secChUa: cache.headers["sec-ch-ua"] || session?.secChUa,
@@ -3594,7 +3631,7 @@ export async function captureQwenHeaders(
3594
3631
  cookie: reqHeaders["cookie"] || "",
3595
3632
  "bx-ua": reqHeaders["bx-ua"] || "",
3596
3633
  "bx-umidtoken": reqHeaders["bx-umidtoken"] || "",
3597
- "bx-v": reqHeaders["bx-v"] || "2.5.37",
3634
+ "bx-v": reqHeaders["bx-v"] || getQwenBxVersion(),
3598
3635
  "user-agent": reqHeaders["user-agent"] || "",
3599
3636
  "x-request-id": reqHeaders["x-request-id"] || "",
3600
3637
  "version": reqHeaders["version"] || "",
@@ -3638,6 +3675,9 @@ export async function captureQwenHeaders(
3638
3675
  if (capturedHeaders["version"]) {
3639
3676
  updateQwenWebVersion(capturedHeaders["version"]);
3640
3677
  }
3678
+ if (capturedHeaders["bx-v"]) {
3679
+ updateQwenBxVersion(capturedHeaders["bx-v"]);
3680
+ }
3641
3681
  markAccountHeadersReady(accountId);
3642
3682
  cache.lastRefresh = Date.now();
3643
3683
  // Header interception can set challenge/session cookies, so do not reuse
@@ -4250,7 +4290,7 @@ async function refreshHeadersInternal(
4250
4290
  saveAuthSession(accountId, {
4251
4291
  cookie: cookieStr,
4252
4292
  userAgent: cache.headers["user-agent"] || "",
4253
- bxV: cache.headers["bx-v"] || "2.5.37",
4293
+ bxV: cache.headers["bx-v"] || getQwenBxVersion(),
4254
4294
  bxUa: cache.headers["bx-ua"] || "",
4255
4295
  bxUmidtoken: cache.headers["bx-umidtoken"] || "",
4256
4296
  secChUa: cache.headers["sec-ch-ua"] || undefined,
@@ -4,8 +4,10 @@ import { config, type ChatMode, isLocalChatMode, isStatelessChatMode } from "../
4
4
  import { logger, isToolcallDebugEnabled } from "../core/logger.ts";
5
5
  import {
6
6
  computeQuotaCooldownMs,
7
+ getAccountCooldownInfo,
7
8
  markAccountRateLimited,
8
9
  } from "../core/account-manager.ts";
10
+ import { recordWafHardBlock } from "../core/waf-isolation.ts";
9
11
  import { mapClientModelToQwen } from "../core/model-alias.ts";
10
12
  import { qwenUrl } from "./qwen-url.ts";
11
13
  import { QwenUpstreamError } from "./qwen-errors.ts";
@@ -23,6 +25,7 @@ async function createQwenChatSession(
23
25
  model: string,
24
26
  accountId?: string,
25
27
  chatMode: ChatMode = "thread",
28
+ captchaRecoveryAttempts = 1,
26
29
  ): Promise<string> {
27
30
  if (isAuthMockEnabled()) {
28
31
  return process.env.TEST_SESSION_ID || "mock-session";
@@ -60,15 +63,32 @@ async function createQwenChatSession(
60
63
  if (!chatId || typeof chatId !== "string") {
61
64
  const rawLower = raw.toLowerCase();
62
65
  if (looksLikeAntiBotChallengeText(raw)) {
63
- try {
64
- const { recoverBaxiaCaptcha } = await import("./captcha-coordinator.ts");
65
- const solved = await recoverBaxiaCaptcha(accountId, "createQwenChatSession", {
66
- challengeBody: raw,
67
- });
68
- if (solved) {
69
- return createQwenChatSession(headers, model, accountId, chatMode);
70
- }
71
- } catch {}
66
+ if (captchaRecoveryAttempts > 0) {
67
+ try {
68
+ const { recoverBaxiaCaptcha } = await import("./captcha-coordinator.ts");
69
+ const solved = await recoverBaxiaCaptcha(accountId, "createQwenChatSession", {
70
+ challengeBody: raw,
71
+ });
72
+ if (solved) {
73
+ return createQwenChatSession(
74
+ headers,
75
+ model,
76
+ accountId,
77
+ chatMode,
78
+ captchaRecoveryAttempts - 1,
79
+ );
80
+ }
81
+ } catch {}
82
+ }
83
+
84
+ // Surface the WAF category to the caller so the warm pool can quarantine
85
+ // the account instead of treating this as a generic invalid payload
86
+ // (which would retry the same flagged device identity forever).
87
+ throw new QwenUpstreamError(
88
+ `Qwen create chat blocked by WAF challenge: ${raw.substring(0, 300)}`,
89
+ "FAIL_SYS_USER_VALIDATE",
90
+ 403,
91
+ );
72
92
  }
73
93
 
74
94
  const isUnauthorized =
@@ -346,6 +366,29 @@ async function refillQwenChatPool(
346
366
  console.warn(
347
367
  `[WarmPool] Account ${accountId || "global"} rate-limited during chat creation. Marked for cooldown.`,
348
368
  );
369
+ } else if (
370
+ accountId &&
371
+ (err.upstreamCode === "FAIL_SYS_USER_VALIDATE" ||
372
+ err.upstreamCode === "RGV587_ERROR" ||
373
+ /_____tmd_____|rgv587_error|fail_sys_user_validate/i.test(
374
+ err.message,
375
+ ))
376
+ ) {
377
+ // A background warm-up that hits an unsolvable challenge must park the
378
+ // account: otherwise the next refill repeats the same flagged request
379
+ // and spreads the WAF block. Guarded so an already-quarantined account
380
+ // is not escalated twice by concurrent refills.
381
+ const existing = getAccountCooldownInfo(accountId);
382
+ if (existing?.reason === "WafChallenge") {
383
+ console.warn(
384
+ `[WarmPool] Account ${accountId} still on WAF quarantine (${Math.ceil(existing.remainingMs / 1000)}s left); skipping re-escalation.`,
385
+ );
386
+ } else {
387
+ const block = recordWafHardBlock(accountId);
388
+ console.warn(
389
+ `[WarmPool] Unsolved WAF challenge on ${accountId} during chat creation; quarantined ${Math.ceil(block.cooldownMs / 1000)}s and rotated the fingerprint.`,
390
+ );
391
+ }
349
392
  }
350
393
  }
351
394
  if (isToolcallDebugEnabled()) {
@@ -21,6 +21,7 @@ export function getDefaultQwenUserAgent(major = getChromeMajor()): string {
21
21
  }
22
22
 
23
23
  let dynamicWebVersion: string | null = null;
24
+ let dynamicBxVersion: string | null = null;
24
25
 
25
26
  export function updateQwenWebVersion(version?: string | null): void {
26
27
  if (version && typeof version === "string" && /^\d+\.\d+\.\d+/.test(version)) {
@@ -32,6 +33,22 @@ export function getQwenWebVersion(): string {
32
33
  return dynamicWebVersion || config.qwen.webVersion;
33
34
  }
34
35
 
36
+ /**
37
+ * Aliyun Baxia publishes its SDK version at `window.baxiaCommon.version` and
38
+ * also sends it as the `bx-v` header. Detecting it at runtime keeps the
39
+ * anti-bot fingerprint aligned when Alibaba ships a new SDK; the static
40
+ * QWEN_BX_V value remains as a last-resort fallback.
41
+ */
42
+ export function updateQwenBxVersion(version?: string | null): void {
43
+ if (version && typeof version === "string" && /^\d+\.\d+\.\d+/.test(version)) {
44
+ dynamicBxVersion = version.trim();
45
+ }
46
+ }
47
+
48
+ export function getQwenBxVersion(): string {
49
+ return dynamicBxVersion || config.auth.bxV;
50
+ }
51
+
35
52
  export const QWEN_WEB_VERSION = config.qwen.webVersion;
36
53
  export const DEFAULT_QWEN_USER_AGENT = getDefaultQwenUserAgent(151);
37
54
  const QWEN_TIMEZONE_HEADER = new Date().toString().split(" (")[0];
@@ -77,7 +94,7 @@ export function buildQwenRequestHeaders(
77
94
  Connection: "keep-alive",
78
95
  "User-Agent": opts.userAgent || getDefaultQwenUserAgent(),
79
96
  "X-Request-Id": uuidv4(),
80
- "bx-v": opts.bxV || "2.5.37",
97
+ "bx-v": opts.bxV || getQwenBxVersion(),
81
98
  source: "web",
82
99
  version: opts.version || getQwenWebVersion(),
83
100
  timezone: opts.extra?.timezone || opts.extra?.Timezone || formatAsciiTimezone(),
@@ -1006,7 +1006,7 @@ export async function requestQwenTextInBrowser(
1006
1006
 
1007
1007
  const evaluateRequest = (page: Page) =>
1008
1008
  page.evaluate(
1009
- async ({ url, method, headers, body, referrer, timeoutMs, tz }: {
1009
+ async ({ url, method, headers, body, referrer, timeoutMs, tz, version }: {
1010
1010
  url: string;
1011
1011
  method: "GET" | "POST" | "DELETE";
1012
1012
  headers: Record<string, string>;
@@ -1014,6 +1014,7 @@ export async function requestQwenTextInBrowser(
1014
1014
  referrer?: string;
1015
1015
  timeoutMs: number;
1016
1016
  tz: string;
1017
+ version: string;
1017
1018
  }): Promise<BrowserTextResponse> => {
1018
1019
  const controller = new AbortController();
1019
1020
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -1038,7 +1039,7 @@ export async function requestQwenTextInBrowser(
1038
1039
  headers: {
1039
1040
  accept: "application/json, text/plain, */*",
1040
1041
  source: "web",
1041
- version: "0.3.11",
1042
+ version,
1042
1043
  "x-request-origin": "https://chat.qwen.ai",
1043
1044
  timezone: tz,
1044
1045
  authorization: `Bearer ${rTok}`,
@@ -1087,6 +1088,7 @@ export async function requestQwenTextInBrowser(
1087
1088
  referrer: options.referrer,
1088
1089
  timeoutMs: options.timeoutMs ?? Math.min(config.timeouts.page, 20_000),
1089
1090
  tz: formatAsciiTimezone(),
1091
+ version: getQwenWebVersion(),
1090
1092
  },
1091
1093
  );
1092
1094
  const recoverOnTimeout = !options.noMutexRecovery;
@@ -2395,7 +2397,7 @@ export async function deleteSingleQwenChat(
2395
2397
 
2396
2398
  const requestHeaders: Record<string, string> = {
2397
2399
  source: "web",
2398
- version: getQwenWebVersion() || "0.3.26",
2400
+ version: getQwenWebVersion(),
2399
2401
  timezone: formatAsciiTimezone(),
2400
2402
  "x-request-id": crypto.randomUUID(),
2401
2403
  Referer: qwenUrl(`/c/${encodeURIComponent(chatId)}`),
@@ -2448,7 +2450,7 @@ export async function fetchRemoteQwenChats(
2448
2450
  }
2449
2451
 
2450
2452
  const requestHeaders: Record<string, string> = {
2451
- version: getQwenWebVersion() || "0.3.26",
2453
+ version: getQwenWebVersion(),
2452
2454
  timezone: formatAsciiTimezone(),
2453
2455
  "x-request-id": crypto.randomUUID(),
2454
2456
  Referer: qwenUrl("/settings/chats"),
@@ -13,6 +13,55 @@ let running = false;
13
13
  let intervalId: ReturnType<typeof setInterval> | null = null;
14
14
  let cycleInProgress = false;
15
15
 
16
+ /**
17
+ * A keep-alive failure (navigation timeout, transient WAF, closed context
18
+ * that is not yet reported as closed) must not retry on every cycle: it would
19
+ * hammer the same broken page and bury the logs. Back off per account with the
20
+ * usual exponential schedule, capped at five minutes.
21
+ */
22
+ const KEEP_ALIVE_BACKOFF_BASE_MS = 2_000;
23
+ const KEEP_ALIVE_BACKOFF_MAX_MS = 5 * 60_000;
24
+
25
+ interface KeepAliveRetryState {
26
+ failures: number;
27
+ nextAttemptAt: number;
28
+ }
29
+
30
+ const keepAliveRetries = new Map<string, KeepAliveRetryState>();
31
+
32
+ export function computeSessionKeeperBackoffMs(failures: number): number {
33
+ const exponent = Math.max(0, Math.min(failures - 1, 8));
34
+ return Math.min(
35
+ KEEP_ALIVE_BACKOFF_MAX_MS,
36
+ KEEP_ALIVE_BACKOFF_BASE_MS * 2 ** exponent,
37
+ );
38
+ }
39
+
40
+ function isKeepAliveBackedOff(accountId: string): boolean {
41
+ const state = keepAliveRetries.get(accountId);
42
+ return Boolean(state && Date.now() < state.nextAttemptAt);
43
+ }
44
+
45
+ function recordKeepAliveFailure(accountId: string): number {
46
+ const state = keepAliveRetries.get(accountId) ?? {
47
+ failures: 0,
48
+ nextAttemptAt: 0,
49
+ };
50
+ state.failures += 1;
51
+ const backoffMs = computeSessionKeeperBackoffMs(state.failures);
52
+ state.nextAttemptAt = Date.now() + backoffMs;
53
+ keepAliveRetries.set(accountId, state);
54
+ return backoffMs;
55
+ }
56
+
57
+ export function resetSessionKeeperRetryStateForTests(): void {
58
+ keepAliveRetries.clear();
59
+ }
60
+
61
+ export function isSessionKeeperAccountBackedOff(accountId: string): boolean {
62
+ return isKeepAliveBackedOff(accountId);
63
+ }
64
+
16
65
  export function isSessionKeeperRunning(): boolean {
17
66
  return running;
18
67
  }
@@ -24,19 +73,31 @@ async function runKeepAliveCycle(): Promise<void> {
24
73
  if (config.sessionKeeper.enabled) {
25
74
  const accountIds = getActivePlaywrightAccountIds();
26
75
  for (const accountId of accountIds) {
27
- await keepAlivePlaywrightAccount(accountId).catch((error) => {
76
+ if (isKeepAliveBackedOff(accountId)) {
77
+ continue;
78
+ }
79
+ try {
80
+ const keptAlive = await keepAlivePlaywrightAccount(accountId);
81
+ if (keptAlive) {
82
+ keepAliveRetries.delete(accountId);
83
+ }
84
+ } catch (error) {
28
85
  // Shutdown/eviction closes contexts while a cycle is in flight; the
29
86
  // resulting "already closed" rejection is benign. The old substring
30
87
  // filter ("Target closed"/"Page is closed") missed Playwright's real
31
88
  // message ("Target page, context or browser has been closed") and
32
89
  // leaked a warning on every Ctrl+C.
33
- if (isPlaywrightAlreadyClosedError(error)) return;
34
- const message =
35
- error instanceof Error ? error.message : String(error);
36
- console.warn(
37
- `[SessionKeeper] Keep-alive failed for ${accountId}: ${message}`,
38
- );
39
- });
90
+ if (isPlaywrightAlreadyClosedError(error)) {
91
+ keepAliveRetries.delete(accountId);
92
+ } else {
93
+ const message =
94
+ error instanceof Error ? error.message : String(error);
95
+ const backoffMs = recordKeepAliveFailure(accountId);
96
+ console.warn(
97
+ `[SessionKeeper] Keep-alive failed for ${accountId}: ${message} (retry in ${Math.round(backoffMs / 1000)}s)`,
98
+ );
99
+ }
100
+ }
40
101
  await sleep(humanDelay(250, 900));
41
102
  }
42
103
  }
@@ -81,6 +142,7 @@ export function stopSessionKeeper(): void {
81
142
  intervalId = null;
82
143
  }
83
144
  cycleInProgress = false;
145
+ keepAliveRetries.clear();
84
146
  }
85
147
 
86
148
  export async function runSessionKeeperOnceForTesting(): Promise<void> {