qwenproxy-cli 1.6.10 → 1.6.11

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.
@@ -17,6 +17,6 @@ export function getAppVersion(): string {
17
17
  }
18
18
  }
19
19
  } catch {}
20
- cachedVersion = "v1.6.10";
20
+ cachedVersion = "v1.6.11";
21
21
  return cachedVersion;
22
22
  }
@@ -148,14 +148,15 @@ export class Watchdog extends EventEmitter {
148
148
  // (idle mutex, no active stream, preserves the max-active-context minimum)
149
149
  // so the next health check sees the freed RSS.
150
150
  try {
151
- const { closeIdlePlaywrightAccounts } = await import(
151
+ const { closeIdlePlaywrightAccounts, evictIdlePlaywrightContextsToLimit } = await import(
152
152
  "../services/playwright.js"
153
153
  );
154
+ const evicted = await evictIdlePlaywrightContextsToLimit(1);
154
155
  const closed = await closeIdlePlaywrightAccounts(
155
156
  config.sessionKeeper.idleMs,
156
157
  );
157
- if (closed > 0) {
158
- this.emit("recovery:ram:contexts-closed", closed);
158
+ if (evicted + closed > 0) {
159
+ this.emit("recovery:ram:contexts-closed", evicted + closed);
159
160
  }
160
161
  } catch {
161
162
  // Playwright may not be initialized (mock mode / pre-start); RSS relief
@@ -8,6 +8,7 @@ import {
8
8
  } from "../../core/account-manager.ts";
9
9
  import { markAccountSuccessful, markAccountFailed, getAccountsByPriority } from "../../core/account-priority.ts";
10
10
  import { recordWafHardBlock, noteWafRecovery } from "../../core/waf-isolation.ts";
11
+ import { isCaptchaRecoveryInBackoff } from "../../services/captcha-coordinator.ts";
11
12
  import { loadAccounts, type QwenAccount } from "../../core/accounts.ts";
12
13
  import { config, type ChatMode } from "../../core/config.ts";
13
14
  import { ClientAbortedError, UpstreamRateLimit, ValidationError } from "../../core/errors.ts";
@@ -1615,6 +1616,17 @@ async function tryCreateStreamWithRetry(
1615
1616
  invalidateLogicalThreadParent(params.sessionId);
1616
1617
  }
1617
1618
 
1619
+ // If captcha recovery on this account already failed and is in its 30s
1620
+ // backoff window, same-account retries will only hit `recovery_skipped`
1621
+ // and waste time/RAM re-sending large payloads. Exit immediately so the
1622
+ // outer loop quarantines this account and switches to a fresh one.
1623
+ if (
1624
+ policy.reason === "anti_bot" &&
1625
+ isCaptchaRecoveryInBackoff(currentAccountId)
1626
+ ) {
1627
+ return { success: false, error: err };
1628
+ }
1629
+
1618
1630
  // A generic invalid_input is often a stale/corrupted upstream chat rather
1619
1631
  // than an account failure. Rebuild it once on the same account first. If the
1620
1632
  // fresh chat fails again, the normal policy is allowed to rotate.
@@ -1259,8 +1259,8 @@ export async function processStreamingResponse(
1259
1259
  };
1260
1260
 
1261
1261
  const emitAnswerText = async (textChunk: string) => {
1262
- if (textChunk) emittedModelOutput = true;
1263
1262
  if (!toolParser) {
1263
+ if (textChunk) emittedModelOutput = true;
1264
1264
  finalContent += textChunk;
1265
1265
  writeDeltaEvent({ content: textChunk });
1266
1266
  return;
@@ -1777,6 +1777,23 @@ export async function processStreamingResponse(
1777
1777
  .replace(/\\\\/g, "\\");
1778
1778
 
1779
1779
  if (unescaped) {
1780
+ if (!emittedModelOutput && isOverloadMessage(unescaped)) {
1781
+ const overloadErr = toRetryableStreamError(
1782
+ "server_overloaded",
1783
+ unescaped,
1784
+ {
1785
+ switchAccount: true,
1786
+ forceNewChat: true,
1787
+ reason: "server_busy",
1788
+ accountCooldownMs: OVERLOAD_COOLDOWN_MS,
1789
+ accountCooldownReason: "ServerOverloaded",
1790
+ },
1791
+ );
1792
+ if (await recoverFromStreamError(overloadErr)) {
1793
+ break;
1794
+ }
1795
+ throw overloadErr;
1796
+ }
1780
1797
  const result = getIncrementalDelta(
1781
1798
  lastRawContent,
1782
1799
  unescaped,
@@ -1942,7 +1959,6 @@ export async function processStreamingResponse(
1942
1959
  if (vStr === "FINISHED") continue;
1943
1960
 
1944
1961
  if (isThinkingChunk) {
1945
- emittedModelOutput = true;
1946
1962
  reasoningBuffer += vStr;
1947
1963
  writeDeltaEvent({ reasoning_content: vStr });
1948
1964
  } else {
@@ -92,6 +92,12 @@ export async function solveChallengeOnPage(
92
92
  * a punish document that is never rendered; opening that document in the page
93
93
  * is what gives the solver a slider to drive.
94
94
  */
95
+ export function isCaptchaRecoveryInBackoff(accountId: string | undefined): boolean {
96
+ if (!accountId) return false;
97
+ const lastFailure = lastFailedRecoveryAt.get(accountId) ?? 0;
98
+ return Date.now() - lastFailure < FAILED_RECOVERY_BACKOFF_MS;
99
+ }
100
+
95
101
  export async function recoverBaxiaCaptcha(
96
102
  accountId: string | undefined,
97
103
  label: string,
@@ -126,19 +132,41 @@ export async function recoverBaxiaCaptcha(
126
132
 
127
133
  activeCaptchaRecoveries.add(accountId);
128
134
  try {
129
- const { abortActiveBrowserStreamsForAccount } = await import("./qwen.ts");
130
- abortActiveBrowserStreamsForAccount(
131
- accountId,
132
- "Account stream interrupted by captcha challenge recovery",
133
- );
134
- } catch {}
135
- try {
135
+ const { hasActiveBrowserStreamsForAccount } = await import("./qwen.ts");
136
+ const hasConcurrentStream = hasActiveBrowserStreamsForAccount(accountId);
137
+
136
138
  // recoverOnTimeout: false ensures that if the captcha solve times out,
137
139
  // withAccountPage does NOT destructively kill the browser context, which
138
140
  // previously caused "Target page, context or browser has been closed" mid-slider.
139
141
  const solved = await withAccountPage(
140
142
  accountId,
141
- (page) => solveChallengeOnPage(page, challengeUrl),
143
+ async (page) => {
144
+ // If another stream is actively running on this account's primary page,
145
+ // NEVER navigate `page` away (which would unload its DOM and kill the stream).
146
+ // Instead, try in-place modal solve first, or open an ephemeral tab in the
147
+ // same BrowserContext so cookies/x5sec are solved without touching `page`.
148
+ if (hasConcurrentStream) {
149
+ const solverOptions = {
150
+ maxAttempts: config.captcha.maxAttempts,
151
+ retryDelayMs: config.captcha.retryDelayMs,
152
+ settleMs: config.captcha.settleMs,
153
+ };
154
+ if (await solveBaxiaCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
155
+ return true;
156
+ }
157
+ const ctx = typeof page.context === "function" ? page.context() : null;
158
+ if (ctx && typeof ctx.newPage === "function") {
159
+ const tempPage = await ctx.newPage();
160
+ try {
161
+ return await solveChallengeOnPage(tempPage, challengeUrl);
162
+ } finally {
163
+ await tempPage.close({ runBeforeUnload: false }).catch(() => {});
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+ return await solveChallengeOnPage(page, challengeUrl);
169
+ },
142
170
  solverOperationTimeoutMs,
143
171
  solverOperationTimeoutMs,
144
172
  false,
@@ -79,7 +79,15 @@ export async function deleteChatsForAccountId(accountId: string): Promise<boolea
79
79
  if (!credentials) {
80
80
  throw new Error(`Credenciais da conta ${account.email} não encontradas.`);
81
81
  }
82
- return deleteChatsForAccount(credentials);
82
+ const wasInitialized = isPlaywrightInitialized(account.id);
83
+ try {
84
+ return await deleteChatsForAccount(credentials);
85
+ } finally {
86
+ if (!wasInitialized && !isAuthMockEnabled()) {
87
+ const { closePlaywrightForAccount } = await import("./playwright.ts");
88
+ await closePlaywrightForAccount(account.id).catch(() => {});
89
+ }
90
+ }
83
91
  }
84
92
 
85
93
  export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false): Promise<DeleteChatsResult> {
@@ -100,6 +108,7 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
100
108
  const account = accounts[i];
101
109
  const currentIdx = i + 1;
102
110
  const totalCount = accounts.length;
111
+ const wasInitialized = isPlaywrightInitialized(account.id);
103
112
  try {
104
113
  const ok = await deleteChatsForAccount(account, currentIdx, totalCount);
105
114
  if (ok) succeeded++;
@@ -109,7 +118,7 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
109
118
  error instanceof Error ? error.message : String(error),
110
119
  );
111
120
  } finally {
112
- if (!keepBrowserOpen) {
121
+ if ((!keepBrowserOpen || !wasInitialized) && !isAuthMockEnabled()) {
113
122
  const { closePlaywrightForAccount } = await import("./playwright.ts");
114
123
  await closePlaywrightForAccount(account.id).catch(() => {});
115
124
  }
@@ -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 {
@@ -1408,18 +1409,19 @@ export async function getBasicHeaders(accountId: string): Promise<{
1408
1409
  } else if (
1409
1410
  hadUsableHeaders &&
1410
1411
  headersAge > HEADER_CACHE_TTL * HEADER_REFRESH_THRESHOLD &&
1411
- !cache.refreshInProgress
1412
+ !cache.refreshInProgress &&
1413
+ // Header capture types into the chat input and sends a probe message on
1414
+ // the account's page. Doing that while the account is serving a stream
1415
+ // would mutate the live conversation (and can navigate the SPA), so the
1416
+ // proactive refresh waits until the account has no active lease.
1417
+ !hasActiveAccountLease(accountId)
1412
1418
  ) {
1413
1419
  // Background refresh at 70% TTL: keep headers fresh without blocking
1414
- cache.refreshInProgress = true;
1415
1420
  refreshHeadersInternal(accountId)
1416
1421
  .catch((error) => {
1417
1422
  console.warn(
1418
1423
  `⚠️ [Playwright] Background header refresh failed for ${accountId}: ${getErrorMessage(error)}`,
1419
1424
  );
1420
- })
1421
- .finally(() => {
1422
- cache.refreshInProgress = false;
1423
1425
  });
1424
1426
  }
1425
1427
 
@@ -1569,8 +1571,13 @@ export async function initPlaywrightForAccount(
1569
1571
  return;
1570
1572
  }
1571
1573
 
1572
- // If a context limit is configured, make room by closing idle contexts.
1573
- await evictIdlePlaywrightContextsToLimit().catch(() => {});
1574
+ // If a context limit is configured, make room BEFORE launching by evicting down to (max - 1)
1575
+ // so the new context brings total active contexts to at most max.
1576
+ if (config.playwright.maxActiveContexts > 0) {
1577
+ await evictIdlePlaywrightContextsToLimit(
1578
+ Math.max(1, config.playwright.maxActiveContexts - 1),
1579
+ ).catch(() => {});
1580
+ }
1574
1581
 
1575
1582
  const profilePath = getAccountProfilePath(account.id);
1576
1583
  const fingerprint = getFingerprintProfile(account.id);
@@ -1882,12 +1889,17 @@ export async function initPlaywrightForAccount(
1882
1889
  await captureQwenHeaders(account.id);
1883
1890
  }
1884
1891
 
1885
- // Header capture may leave the UI on a generated chat page. Return the
1886
- // primary tab to the canonical chat home.
1892
+ // Header capture may leave the UI on a punish or auth page. Return the
1893
+ // primary tab to the canonical chat home only when not already on a clean Qwen page.
1887
1894
  if (!acctPage.isClosed()) {
1888
1895
  try {
1889
1896
  const currentUrl = new URL(acctPage.url());
1890
- if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
1897
+ if (
1898
+ currentUrl.origin !== qwenOrigin() ||
1899
+ currentUrl.pathname.includes("/auth") ||
1900
+ currentUrl.pathname.includes("/login") ||
1901
+ currentUrl.pathname.includes("_____tmd_____")
1902
+ ) {
1891
1903
  await acctPage.goto(qwenUrl("/"), {
1892
1904
  waitUntil: "domcontentloaded",
1893
1905
  timeout: config.timeouts.navigation,
@@ -1907,6 +1919,7 @@ export async function initPlaywrightForAccount(
1907
1919
  } finally {
1908
1920
  release();
1909
1921
  }
1922
+ await evictIdlePlaywrightContextsToLimit().catch(() => {});
1910
1923
  })();
1911
1924
 
1912
1925
  try {
@@ -4171,19 +4184,32 @@ async function refreshHeadersInternal(
4171
4184
  await executeReauth();
4172
4185
  } else {
4173
4186
  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();
4187
+ const currentUrl = typeof page.url === "function" ? page.url() : "";
4188
+ const needsNavigation =
4189
+ !currentUrl.startsWith(qwenOrigin()) ||
4190
+ currentUrl.includes("/auth") ||
4191
+ currentUrl.includes("/login") ||
4192
+ currentUrl.includes("_____tmd_____");
4193
+
4194
+ if (needsNavigation) {
4195
+ await page.goto(qwenUrl("/"), {
4196
+ waitUntil: "domcontentloaded",
4197
+ timeout: Math.min(
4198
+ config.timeouts.navigation,
4199
+ boundedTimeoutMs,
4200
+ SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
4201
+ ),
4202
+ });
4203
+ await sleep(2000);
4204
+ }
4205
+
4206
+ const url = typeof page.url === "function" ? page.url() : "";
4184
4207
  const isAuthUrl = url.includes("auth") || url.includes("login");
4185
4208
  if (isAuthUrl) {
4186
- await executeReauth();
4209
+ const refreshed = await refreshAccountToken(accountId);
4210
+ if (!refreshed.success) {
4211
+ await executeReauth();
4212
+ }
4187
4213
  }
4188
4214
  } catch (navErr) {
4189
4215
  console.warn(
@@ -4243,12 +4269,18 @@ async function refreshHeadersInternal(
4243
4269
 
4244
4270
  await captureQwenHeaders(accountId, undefined, boundedTimeoutMs);
4245
4271
 
4246
- // Best-effort restore: header capture can leave the tab on a chat page.
4272
+ // Best-effort restore: if header capture left the tab on a punish/auth URL,
4273
+ // return to the canonical chat home without reloading an already-clean Qwen page.
4247
4274
  const capturedPage = accountPages.get(accountId);
4248
4275
  if (capturedPage && !capturedPage.isClosed()) {
4249
4276
  try {
4250
4277
  const currentUrl = new URL(capturedPage.url());
4251
- if (currentUrl.origin !== qwenOrigin() || currentUrl.pathname !== "/") {
4278
+ if (
4279
+ currentUrl.origin !== qwenOrigin() ||
4280
+ currentUrl.pathname.includes("/auth") ||
4281
+ currentUrl.pathname.includes("/login") ||
4282
+ currentUrl.pathname.includes("_____tmd_____")
4283
+ ) {
4252
4284
  await capturedPage.goto(qwenUrl("/"), {
4253
4285
  waitUntil: "domcontentloaded",
4254
4286
  timeout: Math.min(config.timeouts.navigation, boundedTimeoutMs),
@@ -4693,7 +4725,13 @@ function priorityOrderForEviction(accountIds: string[]): string[] {
4693
4725
  const ranked = getAccountsByPriority(accountIds.map((id) => ({ id })));
4694
4726
  const rank = new Map<string, number>();
4695
4727
  for (const [index, account] of ranked.entries()) {
4696
- rank.set(account.id, index);
4728
+ // Accounts currently on cooldown (RateLimited / MembershipLimit / WafChallenge)
4729
+ // cannot serve traffic and must be evicted before any healthy warm account.
4730
+ if (getAccountCooldownInfo(account.id)) {
4731
+ rank.set(account.id, Number.MAX_SAFE_INTEGER);
4732
+ } else {
4733
+ rank.set(account.id, index);
4734
+ }
4697
4735
  }
4698
4736
  return orderContextsForEviction(
4699
4737
  accountIds,
@@ -4750,15 +4788,21 @@ export async function closeIdlePlaywrightAccounts(
4750
4788
  * PLAYWRIGHT_MAX_ACTIVE_CONTEXTS. Never closes a context whose account mutex
4751
4789
  * is busy, so active streams are preserved.
4752
4790
  */
4753
- export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
4754
- const max = config.playwright.maxActiveContexts;
4755
- if (max <= 0) return 0;
4791
+ export async function evictIdlePlaywrightContextsToLimit(
4792
+ targetLimit?: number,
4793
+ ): Promise<number> {
4794
+ const configuredMax = config.playwright.maxActiveContexts;
4795
+ if (configuredMax <= 0) return 0;
4796
+ const max = targetLimit !== undefined ? Math.max(0, targetLimit) : configuredMax;
4756
4797
  if (accountPages.size <= max) return 0;
4757
4798
 
4758
4799
  const candidates = priorityOrderForEviction(
4759
4800
  Array.from(accountPages.keys()).filter((accountId) => {
4760
4801
  if (isAccountServingStream(accountId)) return false;
4761
- if (isAccountRecentlyActive(accountId, 30_000)) return false;
4802
+ // A request may hold a stream lease while still acquiring the page mutex
4803
+ // (pre-stream setup). Closing that context would fail the request even
4804
+ // though the mutex still looks idle, so treat any lease as "in use".
4805
+ if (hasActiveAccountLease(accountId)) return false;
4762
4806
  const mutex = accountMutexes.get(accountId);
4763
4807
  return mutex?.isIdle();
4764
4808
  }),
@@ -4772,6 +4816,7 @@ export async function evictIdlePlaywrightContextsToLimit(): Promise<number> {
4772
4816
  for (const candidate of candidates) {
4773
4817
  if (accountPages.size <= max) break;
4774
4818
  if (isAccountServingStream(candidate.accountId)) continue;
4819
+ if (hasActiveAccountLease(candidate.accountId)) continue;
4775
4820
  const mutex = accountMutexes.get(candidate.accountId);
4776
4821
  if (!mutex?.isIdle()) continue;
4777
4822
 
@@ -129,6 +129,20 @@ const browserStreamStates = new Map<string, BrowserStreamState>();
129
129
  const browserStreamBindingPages = new WeakSet<object>();
130
130
  const browserStreamBindingContexts = new WeakSet<object>();
131
131
 
132
+ /**
133
+ * Whether the account currently has an in-flight browser stream. Captcha
134
+ * recovery uses this to avoid navigating the page that is feeding a live
135
+ * stream (it opens an ephemeral tab instead).
136
+ */
137
+ export function hasActiveBrowserStreamsForAccount(accountId: string): boolean {
138
+ for (const state of browserStreamStates.values()) {
139
+ if (state.accountId === accountId && !state.done) {
140
+ return true;
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+
132
146
  /**
133
147
  * Abort all active browser streams for an account immediately.
134
148
  * Called when an account page is navigated by captcha recovery, closed, or reset,
@@ -1364,12 +1378,22 @@ async function createQwenBrowserResponse(
1364
1378
  await ensureBrowserStreamBinding(page);
1365
1379
  if (typeof (page as any).on === "function") {
1366
1380
  const onFrameNavigated = (frame: any) => {
1367
- if (!settled && typeof (page as any).mainFrame === "function" && frame === (page as any).mainFrame()) {
1368
- const url = typeof frame.url === "function" ? frame.url() : String(frame.url || "");
1369
- // Safe SPA in-app routing within Qwen Web (e.g. /c/<id>, /#) must NEVER abort active streams
1381
+ if (
1382
+ !settled &&
1383
+ typeof (page as any).mainFrame === "function" &&
1384
+ frame === (page as any).mainFrame()
1385
+ ) {
1386
+ const url =
1387
+ typeof frame.url === "function"
1388
+ ? frame.url()
1389
+ : String(frame.url || "");
1390
+ // Same-origin SPA routing within Qwen Web (e.g. /c/<id>, /#) keeps
1391
+ // the live DOM and MUST NOT abort the stream. Only a genuine
1392
+ // unload (auth, Baxia punish, or another origin) kills the fetch.
1370
1393
  if (
1371
1394
  url.startsWith(qwenOrigin()) &&
1372
1395
  !url.includes("/auth") &&
1396
+ !url.includes("/login") &&
1373
1397
  !url.includes("_____tmd_____") &&
1374
1398
  !url.includes("/punish")
1375
1399
  ) {
@@ -1393,10 +1417,12 @@ async function createQwenBrowserResponse(
1393
1417
  };
1394
1418
  (page as any).on("framenavigated", onFrameNavigated);
1395
1419
  (page as any).on("close", onPageClosed);
1420
+ (page as any).on("crash", onPageClosed);
1396
1421
  pageCleanup = () => {
1397
1422
  try {
1398
1423
  (page as any).off?.("framenavigated", onFrameNavigated);
1399
1424
  (page as any).off?.("close", onPageClosed);
1425
+ (page as any).off?.("crash", onPageClosed);
1400
1426
  } catch {}
1401
1427
  };
1402
1428
  }