qwenproxy-cli 1.6.10 → 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.
@@ -68,6 +68,7 @@ import {
68
68
  unmarkAccountHeadersReady,
69
69
  isAccountHeadersReady,
70
70
  markAccountRateLimited,
71
+ getAccountCooldownInfo,
71
72
  } from "../core/account-manager.ts";
72
73
  import { getAccountsByPriority } from "../core/account-priority.ts";
73
74
  import {
@@ -77,10 +78,15 @@ import {
77
78
  type FingerprintProfile,
78
79
  } from "./fingerprint.ts";
79
80
  import { subtlePageActivity } from "./human-behavior.ts";
80
- import { solveBaxiaCaptcha } from "./captcha-solver.ts";
81
+ import { solveVisibleCaptcha } from "./captcha-solver.ts";
81
82
  import { qwenOrigin, qwenUrl } from "./qwen-url.ts";
82
83
  import { setWafContextResetListener } from "../core/waf-isolation.ts";
83
- import { updateQwenWebVersion, getQwenWebVersion } from "./qwen-headers.ts";
84
+ import {
85
+ getQwenBxVersion,
86
+ getQwenWebVersion,
87
+ updateQwenBxVersion,
88
+ updateQwenWebVersion,
89
+ } from "./qwen-headers.ts";
84
90
  import { getAccountProfilePath, getProfilesDir } from "../core/paths.ts";
85
91
  import { parseJwtExpiry, isTokenExpiringSoon } from "../utils/jwt.ts";
86
92
  import { formatAsciiTimezone } from "../utils/timezone.ts";
@@ -597,13 +603,14 @@ const CHAT_INPUT_ACTION_TIMEOUT_MS = 10_000;
597
603
  */
598
604
  async function clearVisibleChallenge(page: Page): Promise<void> {
599
605
  if (page.isClosed()) return;
600
- // 1. Solve slider puzzle captcha if present
606
+ // 1. Solve a slider/jigsaw challenge if present (Baxia NC or Aliyun WAF)
601
607
  if (config.captcha.enabled) {
602
- await solveBaxiaCaptcha(page, {
608
+ await solveVisibleCaptcha(page, {
603
609
  waitForMs: 0,
604
610
  maxAttempts: config.captcha.maxAttempts,
605
611
  retryDelayMs: config.captcha.retryDelayMs,
606
612
  settleMs: config.captcha.settleMs,
613
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
607
614
  }).catch(() => false);
608
615
  }
609
616
 
@@ -1306,13 +1313,22 @@ export async function getBasicHeaders(accountId: string): Promise<{
1306
1313
  mobile = data.mobile ? "?1" : "?0";
1307
1314
  }
1308
1315
  let bundleVersion: string | null = null;
1316
+ let baxiaVersion: string | null = null;
1309
1317
  try {
1310
1318
  const el = document.querySelector('link[href*="qwen-chat-fe"], script[src*="qwen-chat-fe"], img[src*="qwen-chat-fe"]');
1311
1319
  const src = el ? (el.getAttribute("href") || el.getAttribute("src") || "") : "";
1312
1320
  const match = src.match(/qwen-chat-fe\/(\d+\.\d+\.\d+)/) || document.documentElement.innerHTML.match(/qwen-chat-fe\/(\d+\.\d+\.\d+)/);
1313
1321
  if (match) bundleVersion = match[1];
1314
1322
  } catch {}
1315
- 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 };
1316
1332
  }),
1317
1333
  config.timeouts.page,
1318
1334
  `User-agent lookup timed out for ${accountId}`,
@@ -1321,6 +1337,9 @@ export async function getBasicHeaders(accountId: string): Promise<{
1321
1337
  if (nav.bundleVersion) {
1322
1338
  updateQwenWebVersion(nav.bundleVersion);
1323
1339
  }
1340
+ if (nav.baxiaVersion) {
1341
+ updateQwenBxVersion(nav.baxiaVersion);
1342
+ }
1324
1343
  cachedUserAgents.set(accountId, userAgent);
1325
1344
  const hints = getHeaderCache(accountId).headers;
1326
1345
  clientHints = {
@@ -1358,7 +1377,7 @@ export async function getBasicHeaders(accountId: string): Promise<{
1358
1377
  markAccountHeadersReady(accountId);
1359
1378
  const bxUa = cache.headers["bx-ua"];
1360
1379
  const bxUmidtoken = cache.headers["bx-umidtoken"];
1361
- const bxV = cache.headers["bx-v"] || "2.5.37";
1380
+ const bxV = cache.headers["bx-v"] || getQwenBxVersion();
1362
1381
  const cookie = await getCookies(accountId);
1363
1382
  return { cookie, userAgent, bxV, bxUa, bxUmidtoken, ...clientHints };
1364
1383
  }
@@ -1382,7 +1401,7 @@ export async function getBasicHeaders(accountId: string): Promise<{
1382
1401
  cookieCaches.set(accountId, { cookie, timestamp: Date.now() });
1383
1402
  const bxUa = cache.headers["bx-ua"];
1384
1403
  const bxUmidtoken = cache.headers["bx-umidtoken"];
1385
- const bxV = cache.headers["bx-v"] || "2.5.37";
1404
+ const bxV = cache.headers["bx-v"] || getQwenBxVersion();
1386
1405
  cache.lastRefresh = Date.now();
1387
1406
  markAccountHeadersReady(accountId);
1388
1407
  console.log(
@@ -1408,18 +1427,19 @@ export async function getBasicHeaders(accountId: string): Promise<{
1408
1427
  } else if (
1409
1428
  hadUsableHeaders &&
1410
1429
  headersAge > HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD &&
1411
- !cache.refreshInProgress
1430
+ !cache.refreshInProgress &&
1431
+ // Header capture types into the chat input and sends a probe message on
1432
+ // the account's page. Doing that while the account is serving a stream
1433
+ // would mutate the live conversation (and can navigate the SPA), so the
1434
+ // proactive refresh waits until the account has no active lease.
1435
+ !hasActiveAccountLease(accountId)
1412
1436
  ) {
1413
1437
  // Background refresh at 70% TTL: keep headers fresh without blocking
1414
- cache.refreshInProgress = true;
1415
1438
  refreshHeadersInternal(accountId)
1416
1439
  .catch((error) => {
1417
1440
  console.warn(
1418
1441
  `⚠️ [Playwright] Background header refresh failed for ${accountId}: ${getErrorMessage(error)}`,
1419
1442
  );
1420
- })
1421
- .finally(() => {
1422
- cache.refreshInProgress = false;
1423
1443
  });
1424
1444
  }
1425
1445
 
@@ -1463,6 +1483,9 @@ export async function getBasicHeaders(accountId: string): Promise<{
1463
1483
  if (persisted.version) {
1464
1484
  updateQwenWebVersion(persisted.version);
1465
1485
  }
1486
+ if (persisted.bxV) {
1487
+ updateQwenBxVersion(persisted.bxV);
1488
+ }
1466
1489
  cache.lastRefresh = persisted.capturedAt;
1467
1490
  markAccountHeadersReady(accountId);
1468
1491
  }
@@ -1492,7 +1515,7 @@ export async function getBasicHeaders(accountId: string): Promise<{
1492
1515
  markAccountHeadersReady(accountId);
1493
1516
  const bxUa = cache.headers["bx-ua"];
1494
1517
  const bxUmidtoken = cache.headers["bx-umidtoken"];
1495
- const bxV = cache.headers["bx-v"] || "2.5.37";
1518
+ const bxV = cache.headers["bx-v"] || getQwenBxVersion();
1496
1519
 
1497
1520
  // Read cookie AFTER all refreshes (re-login may have updated it)
1498
1521
  cookieCaches.delete(accountId);
@@ -1569,8 +1592,13 @@ export async function initPlaywrightForAccount(
1569
1592
  return;
1570
1593
  }
1571
1594
 
1572
- // If a context limit is configured, make room by closing idle contexts.
1573
- await evictIdlePlaywrightContextsToLimit().catch(() => {});
1595
+ // If a context limit is configured, make room BEFORE launching by evicting down to (max - 1)
1596
+ // so the new context brings total active contexts to at most max.
1597
+ if (config.playwright.maxActiveContexts > 0) {
1598
+ await evictIdlePlaywrightContextsToLimit(
1599
+ Math.max(1, config.playwright.maxActiveContexts - 1),
1600
+ ).catch(() => {});
1601
+ }
1574
1602
 
1575
1603
  const profilePath = getAccountProfilePath(account.id);
1576
1604
  const fingerprint = getFingerprintProfile(account.id);
@@ -1779,6 +1807,9 @@ export async function initPlaywrightForAccount(
1779
1807
  if (persisted.version) {
1780
1808
  updateQwenWebVersion(persisted.version);
1781
1809
  }
1810
+ if (persisted.bxV) {
1811
+ updateQwenBxVersion(persisted.bxV);
1812
+ }
1782
1813
  cache.lastRefresh = persisted.capturedAt;
1783
1814
  markAccountHeadersReady(account.id);
1784
1815
  restoredFromDb = true;
@@ -1882,12 +1913,17 @@ export async function initPlaywrightForAccount(
1882
1913
  await captureQwenHeaders(account.id);
1883
1914
  }
1884
1915
 
1885
- // Header capture may leave the UI on a generated chat page. Return the
1886
- // primary tab to the canonical chat home.
1916
+ // Header capture may leave the UI on a punish or auth page. Return the
1917
+ // primary tab to the canonical chat home only when not already on a clean Qwen page.
1887
1918
  if (!acctPage.isClosed()) {
1888
1919
  try {
1889
1920
  const currentUrl = new URL(acctPage.url());
1890
- if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
1921
+ if (
1922
+ currentUrl.origin !== qwenOrigin() ||
1923
+ currentUrl.pathname.includes("/auth") ||
1924
+ currentUrl.pathname.includes("/login") ||
1925
+ currentUrl.pathname.includes("_____tmd_____")
1926
+ ) {
1891
1927
  await acctPage.goto(qwenUrl("/"), {
1892
1928
  waitUntil: "domcontentloaded",
1893
1929
  timeout: config.timeouts.navigation,
@@ -1907,6 +1943,7 @@ export async function initPlaywrightForAccount(
1907
1943
  } finally {
1908
1944
  release();
1909
1945
  }
1946
+ await evictIdlePlaywrightContextsToLimit().catch(() => {});
1910
1947
  })();
1911
1948
 
1912
1949
  try {
@@ -2226,7 +2263,7 @@ export async function startEmailOtpLogin(
2226
2263
  {
2227
2264
  email: normalizedEmail,
2228
2265
  requestId,
2229
- version: getQwenWebVersion() || "0.3.26",
2266
+ version: getQwenWebVersion(),
2230
2267
  timezone: formatAsciiTimezone(),
2231
2268
  },
2232
2269
  ).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
@@ -2338,7 +2375,7 @@ export async function verifyEmailOtpLogin(
2338
2375
  email: session.email,
2339
2376
  code: cleanCode,
2340
2377
  requestId,
2341
- version: getQwenWebVersion() || "0.3.26",
2378
+ version: getQwenWebVersion(),
2342
2379
  timezone: formatAsciiTimezone(),
2343
2380
  },
2344
2381
  ).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
@@ -2395,8 +2432,8 @@ export async function verifyEmailOtpLogin(
2395
2432
  saveAuthSession(session.accountId, {
2396
2433
  cookie: `token=${accessToken}`,
2397
2434
  userAgent: session.fingerprint.userAgent,
2398
- bxV: "2.5.37",
2399
- version: getQwenWebVersion() || "0.3.26",
2435
+ bxV: getQwenBxVersion(),
2436
+ version: getQwenWebVersion(),
2400
2437
  userId: userId || undefined,
2401
2438
  tokenExpiresAt: exp || undefined,
2402
2439
  refreshToken: refreshToken || undefined,
@@ -2552,7 +2589,7 @@ async function loginToQwen(
2552
2589
  saveAuthSession(accountId, {
2553
2590
  cookie: cookieStr,
2554
2591
  userAgent: cache.headers["user-agent"] || "",
2555
- bxV: cache.headers["bx-v"] || "2.5.37",
2592
+ bxV: cache.headers["bx-v"] || getQwenBxVersion(),
2556
2593
  bxUa: cache.headers["bx-ua"] || "",
2557
2594
  bxUmidtoken: cache.headers["bx-umidtoken"] || "",
2558
2595
  secChUa: cache.headers["sec-ch-ua"] || undefined,
@@ -2628,7 +2665,7 @@ async function loginToQwen(
2628
2665
  saveAuthSession(accountId, {
2629
2666
  cookie: cookieStr,
2630
2667
  userAgent: cache.headers["user-agent"] || "",
2631
- bxV: cache.headers["bx-v"] || "2.5.37",
2668
+ bxV: cache.headers["bx-v"] || getQwenBxVersion(),
2632
2669
  bxUa: cache.headers["bx-ua"] || "",
2633
2670
  bxUmidtoken: cache.headers["bx-umidtoken"] || "",
2634
2671
  secChUa: cache.headers["sec-ch-ua"] || undefined,
@@ -2713,7 +2750,7 @@ async function loginViaApi(
2713
2750
 
2714
2751
  const signinUrl = "https://auth.qwen.ai/api/v2/auths/signin";
2715
2752
  const requestId = crypto.randomUUID();
2716
- const webVersion = getQwenWebVersion() || "0.3.26";
2753
+ const webVersion = getQwenWebVersion();
2717
2754
 
2718
2755
  const evalRes = await page
2719
2756
  .evaluate(
@@ -2922,6 +2959,18 @@ async function loginViaUi(
2922
2959
  return { success: true };
2923
2960
  }
2924
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
+
2925
2974
  // Wait for email input
2926
2975
  const emailSelector = [
2927
2976
  'input[type="email"]',
@@ -3116,12 +3165,13 @@ async function loginViaUi(
3116
3165
  while (Date.now() < postSubmitDeadline) {
3117
3166
  if (page.isClosed()) break;
3118
3167
 
3119
- // If a slider puzzle captcha appeared after submit, solve it automatically
3168
+ // If a slider/jigsaw challenge appeared after submit, solve it automatically
3120
3169
  if (config.captcha.enabled) {
3121
- await solveBaxiaCaptcha(page, {
3170
+ await solveVisibleCaptcha(page, {
3122
3171
  waitForMs: 1500,
3123
3172
  maxAttempts: config.captcha.maxAttempts,
3124
3173
  retryDelayMs: config.captcha.retryDelayMs,
3174
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
3125
3175
  }).catch(() => {});
3126
3176
  }
3127
3177
 
@@ -3268,7 +3318,7 @@ export async function refreshAccountToken(
3268
3318
  // 2. If we have a refresh token, invoke the working refresh endpoint in-page
3269
3319
  if (refreshToken) {
3270
3320
  try {
3271
- const webVersion = getQwenWebVersion() || "0.3.26";
3321
+ const webVersion = getQwenWebVersion();
3272
3322
  const refreshResult = await page.evaluate(
3273
3323
  async ({ rTok, tz, version }) => {
3274
3324
  try {
@@ -3341,7 +3391,7 @@ export async function refreshAccountToken(
3341
3391
  saveAuthSession(accountId, {
3342
3392
  cookie: cookieStr,
3343
3393
  userAgent: cache.headers["user-agent"] || session?.userAgent || "",
3344
- bxV: cache.headers["bx-v"] || session?.bxV || "2.5.37",
3394
+ bxV: cache.headers["bx-v"] || session?.bxV || getQwenBxVersion(),
3345
3395
  bxUa: cache.headers["bx-ua"] || session?.bxUa || "",
3346
3396
  bxUmidtoken: cache.headers["bx-umidtoken"] || session?.bxUmidtoken || "",
3347
3397
  secChUa: cache.headers["sec-ch-ua"] || session?.secChUa,
@@ -3378,7 +3428,7 @@ export async function refreshAccountToken(
3378
3428
  saveAuthSession(accountId, {
3379
3429
  cookie: cookieStr,
3380
3430
  userAgent: cache.headers["user-agent"] || session?.userAgent || "",
3381
- bxV: cache.headers["bx-v"] || session?.bxV || "2.5.37",
3431
+ bxV: cache.headers["bx-v"] || session?.bxV || getQwenBxVersion(),
3382
3432
  bxUa: cache.headers["bx-ua"] || session?.bxUa || "",
3383
3433
  bxUmidtoken: cache.headers["bx-umidtoken"] || session?.bxUmidtoken || "",
3384
3434
  secChUa: cache.headers["sec-ch-ua"] || session?.secChUa,
@@ -3581,7 +3631,7 @@ export async function captureQwenHeaders(
3581
3631
  cookie: reqHeaders["cookie"] || "",
3582
3632
  "bx-ua": reqHeaders["bx-ua"] || "",
3583
3633
  "bx-umidtoken": reqHeaders["bx-umidtoken"] || "",
3584
- "bx-v": reqHeaders["bx-v"] || "2.5.37",
3634
+ "bx-v": reqHeaders["bx-v"] || getQwenBxVersion(),
3585
3635
  "user-agent": reqHeaders["user-agent"] || "",
3586
3636
  "x-request-id": reqHeaders["x-request-id"] || "",
3587
3637
  "version": reqHeaders["version"] || "",
@@ -3625,6 +3675,9 @@ export async function captureQwenHeaders(
3625
3675
  if (capturedHeaders["version"]) {
3626
3676
  updateQwenWebVersion(capturedHeaders["version"]);
3627
3677
  }
3678
+ if (capturedHeaders["bx-v"]) {
3679
+ updateQwenBxVersion(capturedHeaders["bx-v"]);
3680
+ }
3628
3681
  markAccountHeadersReady(accountId);
3629
3682
  cache.lastRefresh = Date.now();
3630
3683
  // Header interception can set challenge/session cookies, so do not reuse
@@ -4171,19 +4224,32 @@ async function refreshHeadersInternal(
4171
4224
  await executeReauth();
4172
4225
  } else {
4173
4226
  try {
4174
- await page.goto(qwenUrl("/"), {
4175
- waitUntil: "domcontentloaded",
4176
- timeout: Math.min(
4177
- config.timeouts.navigation,
4178
- boundedTimeoutMs,
4179
- SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
4180
- ),
4181
- });
4182
- await sleep(2000);
4183
- const url = page.url();
4227
+ const currentUrl = typeof page.url === "function" ? page.url() : "";
4228
+ const needsNavigation =
4229
+ !currentUrl.startsWith(qwenOrigin()) ||
4230
+ currentUrl.includes("/auth") ||
4231
+ currentUrl.includes("/login") ||
4232
+ currentUrl.includes("_____tmd_____");
4233
+
4234
+ if (needsNavigation) {
4235
+ await page.goto(qwenUrl("/"), {
4236
+ waitUntil: "domcontentloaded",
4237
+ timeout: Math.min(
4238
+ config.timeouts.navigation,
4239
+ boundedTimeoutMs,
4240
+ SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
4241
+ ),
4242
+ });
4243
+ await sleep(2000);
4244
+ }
4245
+
4246
+ const url = typeof page.url === "function" ? page.url() : "";
4184
4247
  const isAuthUrl = url.includes("auth") || url.includes("login");
4185
4248
  if (isAuthUrl) {
4186
- await executeReauth();
4249
+ const refreshed = await refreshAccountToken(accountId);
4250
+ if (!refreshed.success) {
4251
+ await executeReauth();
4252
+ }
4187
4253
  }
4188
4254
  } catch (navErr) {
4189
4255
  console.warn(
@@ -4224,7 +4290,7 @@ async function refreshHeadersInternal(
4224
4290
  saveAuthSession(accountId, {
4225
4291
  cookie: cookieStr,
4226
4292
  userAgent: cache.headers["user-agent"] || "",
4227
- bxV: cache.headers["bx-v"] || "2.5.37",
4293
+ bxV: cache.headers["bx-v"] || getQwenBxVersion(),
4228
4294
  bxUa: cache.headers["bx-ua"] || "",
4229
4295
  bxUmidtoken: cache.headers["bx-umidtoken"] || "",
4230
4296
  secChUa: cache.headers["sec-ch-ua"] || undefined,
@@ -4243,12 +4309,18 @@ async function refreshHeadersInternal(
4243
4309
 
4244
4310
  await captureQwenHeaders(accountId, undefined, boundedTimeoutMs);
4245
4311
 
4246
- // Best-effort restore: header capture can leave the tab on a chat page.
4312
+ // Best-effort restore: if header capture left the tab on a punish/auth URL,
4313
+ // return to the canonical chat home without reloading an already-clean Qwen page.
4247
4314
  const capturedPage = accountPages.get(accountId);
4248
4315
  if (capturedPage && !capturedPage.isClosed()) {
4249
4316
  try {
4250
4317
  const currentUrl = new URL(capturedPage.url());
4251
- if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
4318
+ if (
4319
+ currentUrl.origin !== qwenOrigin() ||
4320
+ currentUrl.pathname.includes("/auth") ||
4321
+ currentUrl.pathname.includes("/login") ||
4322
+ currentUrl.pathname.includes("_____tmd_____")
4323
+ ) {
4252
4324
  await capturedPage.goto(qwenUrl("/"), {
4253
4325
  waitUntil: "domcontentloaded",
4254
4326
  timeout: Math.min(config.timeouts.navigation, boundedTimeoutMs),
@@ -4693,7 +4765,13 @@ function priorityOrderForEviction(accountIds: string[]): string[] {
4693
4765
  const ranked = getAccountsByPriority(accountIds.map((id) => ({ id })));
4694
4766
  const rank = new Map<string, number>();
4695
4767
  for (const [index, account] of ranked.entries()) {
4696
- rank.set(account.id, index);
4768
+ // Accounts currently on cooldown (RateLimited / MembershipLimit / WafChallenge)
4769
+ // cannot serve traffic and must be evicted before any healthy warm account.
4770
+ if (getAccountCooldownInfo(account.id)) {
4771
+ rank.set(account.id, Number.MAX_SAFE_INTEGER);
4772
+ } else {
4773
+ rank.set(account.id, index);
4774
+ }
4697
4775
  }
4698
4776
  return orderContextsForEviction(
4699
4777
  accountIds,
@@ -4750,15 +4828,21 @@ export async function closeIdlePlaywrightAccounts(
4750
4828
  * PLAYWRIGHT_MAX_ACTIVE_CONTEXTS. Never closes a context whose account mutex
4751
4829
  * is busy, so active streams are preserved.
4752
4830
  */
4753
- export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
4754
- const max = config.playwright.maxActiveContexts;
4755
- if (max <= 0) return 0;
4831
+ export async function evictIdlePlaywrightContextsToLimit(
4832
+ targetLimit?: number,
4833
+ ): Promise<number> {
4834
+ const configuredMax = config.playwright.maxActiveContexts;
4835
+ if (configuredMax <= 0) return 0;
4836
+ const max = targetLimit !== undefined ? Math.max(0, targetLimit) : configuredMax;
4756
4837
  if (accountPages.size <= max) return 0;
4757
4838
 
4758
4839
  const candidates = priorityOrderForEviction(
4759
4840
  Array.from(accountPages.keys()).filter((accountId) => {
4760
4841
  if (isAccountServingStream(accountId)) return false;
4761
- if (isAccountRecentlyActive(accountId, 30_000)) return false;
4842
+ // A request may hold a stream lease while still acquiring the page mutex
4843
+ // (pre-stream setup). Closing that context would fail the request even
4844
+ // though the mutex still looks idle, so treat any lease as "in use".
4845
+ if (hasActiveAccountLease(accountId)) return false;
4762
4846
  const mutex = accountMutexes.get(accountId);
4763
4847
  return mutex?.isIdle();
4764
4848
  }),
@@ -4772,6 +4856,7 @@ export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
4772
4856
  for (const candidate of candidates) {
4773
4857
  if (accountPages.size <= max) break;
4774
4858
  if (isAccountServingStream(candidate.accountId)) continue;
4859
+ if (hasActiveAccountLease(candidate.accountId)) continue;
4775
4860
  const mutex = accountMutexes.get(candidate.accountId);
4776
4861
  if (!mutex?.isIdle()) continue;
4777
4862
 
@@ -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(),