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.
@@ -119,7 +119,7 @@ var envSchema = z.object({
119
119
  // Version header on Qwen API requests = the deployed web bundle version
120
120
  // (fallback when dynamic DOM detection is not yet completed).
121
121
  // Override via env when Qwen ships a new bundle.
122
- QWEN_WEB_VERSION: z.string().default("0.3.26"),
122
+ QWEN_WEB_VERSION: z.string().default("0.3.43"),
123
123
  // Controls bx-ua/bx-umidtoken injection on the GENERAL API paths
124
124
  // (chats/new, settings): those work without them (live-probed). The
125
125
  // completions path is the exception — the 0.2.86 HAR shows the real
@@ -162,6 +162,7 @@ var envSchema = z.object({
162
162
  CAPTCHA_SOLVER_ENABLED: z.string().default("true"),
163
163
  CAPTCHA_SOLVER_MAX_ATTEMPTS: z.string().default("3"),
164
164
  CAPTCHA_SOLVER_TIMEOUT_MS: z.string().default("15000"),
165
+ CAPTCHA_SLIDER_TIMEOUT_MS: z.string().default("8000"),
165
166
  CAPTCHA_SOLVER_RETRY_DELAY_MS: z.string().default("1000"),
166
167
  CAPTCHA_SOLVER_SETTLE_MS: z.string().default("2000"),
167
168
  CAPTCHA_ACCOUNT_COOLDOWN_MS: z.string().default("120000"),
@@ -313,6 +314,8 @@ var config = {
313
314
  enabled: env.CAPTCHA_SOLVER_ENABLED === "true",
314
315
  maxAttempts: Math.max(1, Math.min(5, parseInt(env.CAPTCHA_SOLVER_MAX_ATTEMPTS))),
315
316
  timeoutMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_TIMEOUT_MS)),
317
+ /** Max wait for a challenge slider to become visible (slow WAF renders). */
318
+ sliderTimeoutMs: Math.max(500, parseInt(env.CAPTCHA_SLIDER_TIMEOUT_MS)),
316
319
  retryDelayMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_RETRY_DELAY_MS)),
317
320
  settleMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_SETTLE_MS)),
318
321
  /** Rest an account whose challenge could not be cleared before reusing it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.6.10",
3
+ "version": "1.6.12",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "bugs": {
59
59
  "url": "https://github.com/johngbl/qwenproxy/issues"
60
60
  },
61
- "author": "johngbl <147551701+johngbl@users.noreply.github.com>",
61
+ "author": "johngbl",
62
62
  "license": "ISC",
63
63
  "dependencies": {
64
64
  "@hono/node-server": "^2.1.1",
package/src/api/server.ts CHANGED
@@ -883,10 +883,14 @@ export async function startServer(options?: {
883
883
  void (async () => {
884
884
  const { ensureAccountInPriority } = await import("../core/account-priority.ts");
885
885
 
886
- const targetWarmTotal = Math.min(
887
- config.playwright.startupWarmAccounts,
888
- totalAccounts,
889
- );
886
+ const maxWarmCap =
887
+ config.playwright.maxActiveContexts > 0
888
+ ? Math.min(
889
+ config.playwright.startupWarmAccounts,
890
+ config.playwright.maxActiveContexts,
891
+ )
892
+ : config.playwright.startupWarmAccounts;
893
+ const targetWarmTotal = Math.min(maxWarmCap, totalAccounts);
890
894
  let currentReadyCount = readyAccountIds.size;
891
895
 
892
896
  if (currentReadyCount < targetWarmTotal && remainingAccounts.length > 0) {
@@ -34,7 +34,7 @@ const envSchema = z
34
34
  // Version header on Qwen API requests = the deployed web bundle version
35
35
  // (fallback when dynamic DOM detection is not yet completed).
36
36
  // Override via env when Qwen ships a new bundle.
37
- QWEN_WEB_VERSION: z.string().default("0.3.26"),
37
+ QWEN_WEB_VERSION: z.string().default("0.3.43"),
38
38
  // Controls bx-ua/bx-umidtoken injection on the GENERAL API paths
39
39
  // (chats/new, settings): those work without them (live-probed). The
40
40
  // completions path is the exception — the 0.2.86 HAR shows the real
@@ -81,6 +81,7 @@ const envSchema = z
81
81
  CAPTCHA_SOLVER_ENABLED: z.string().default("true"),
82
82
  CAPTCHA_SOLVER_MAX_ATTEMPTS: z.string().default("3"),
83
83
  CAPTCHA_SOLVER_TIMEOUT_MS: z.string().default("15000"),
84
+ CAPTCHA_SLIDER_TIMEOUT_MS: z.string().default("8000"),
84
85
  CAPTCHA_SOLVER_RETRY_DELAY_MS: z.string().default("1000"),
85
86
  CAPTCHA_SOLVER_SETTLE_MS: z.string().default("2000"),
86
87
  CAPTCHA_ACCOUNT_COOLDOWN_MS: z.string().default("120000"),
@@ -243,6 +244,8 @@ export const config = {
243
244
  enabled: env.CAPTCHA_SOLVER_ENABLED === "true",
244
245
  maxAttempts: Math.max(1, Math.min(5, parseInt(env.CAPTCHA_SOLVER_MAX_ATTEMPTS))),
245
246
  timeoutMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_TIMEOUT_MS)),
247
+ /** Max wait for a challenge slider to become visible (slow WAF renders). */
248
+ sliderTimeoutMs: Math.max(500, parseInt(env.CAPTCHA_SLIDER_TIMEOUT_MS)),
246
249
  retryDelayMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_RETRY_DELAY_MS)),
247
250
  settleMs: Math.max(0, parseInt(env.CAPTCHA_SOLVER_SETTLE_MS)),
248
251
  /** Rest an account whose challenge could not be cleared before reusing it. */
@@ -9,6 +9,7 @@ export type ReasoningMode = "auto" | "thinking" | "fast";
9
9
  export const DEFAULT_FALLBACK_MODELS = [
10
10
  "qwen3.8-max",
11
11
  "qwen3.7-plus",
12
+ "qwen4.0-max-preview",
12
13
  "qwen3.8-omni-flash",
13
14
  "qwen3.7-max",
14
15
  "qwen3.6-plus",
@@ -17,6 +17,6 @@ export function getAppVersion(): string {
17
17
  }
18
18
  }
19
19
  } catch {}
20
- cachedVersion = "v1.6.10";
20
+ cachedVersion = "v1.6.12";
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 {
@@ -1,6 +1,7 @@
1
1
  import { AuthError } from "../core/errors.ts";
2
2
  import { getAccountCredentials, loadAccounts } from "../core/accounts.ts";
3
3
  import { config } from "../core/config.ts";
4
+ import { getQwenBxVersion, getQwenWebVersion } from "./qwen-headers.ts";
4
5
  import {
5
6
  getBasicHeaders as getPlaywrightBasicHeaders,
6
7
  initPlaywrightForAccount,
@@ -78,13 +79,13 @@ export async function getBasicHeaders(accountId?: string): Promise<{
78
79
  return {
79
80
  cookie: "token=mock",
80
81
  userAgent: "mock",
81
- bxV: "2.5.37",
82
+ bxV: getQwenBxVersion(),
82
83
  bxUa: "mock-bx-ua",
83
84
  bxUmidtoken: "mock-bx-umidtoken",
84
85
  secChUa: "",
85
86
  secChUaMobile: "?0",
86
87
  secChUaPlatform: "",
87
- version: "0.3.26",
88
+ version: getQwenWebVersion(),
88
89
  };
89
90
  }
90
91
 
@@ -7,7 +7,7 @@ import {
7
7
  extractBaxiaChallengeUrl,
8
8
  logBaxiaCaptcha,
9
9
  sanitizeCaptchaErrorDetail,
10
- solveBaxiaCaptcha,
10
+ solveVisibleCaptcha,
11
11
  } from "./captcha-solver.ts";
12
12
 
13
13
  const CHALLENGE_NAVIGATION_TIMEOUT_MS = 8_000;
@@ -55,12 +55,13 @@ export async function solveChallengeOnPage(
55
55
  maxAttempts: config.captcha.maxAttempts,
56
56
  retryDelayMs: config.captcha.retryDelayMs,
57
57
  settleMs: config.captcha.settleMs,
58
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
58
59
  };
59
60
 
60
61
  // Qwen's own Baxia SDK sometimes renders the dialog for the background
61
62
  // fetch. When it did, solve it in place: navigating away would discard the
62
63
  // challenge the SDK is waiting on.
63
- if (await solveBaxiaCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
64
+ if (await solveVisibleCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
64
65
  return true;
65
66
  }
66
67
 
@@ -72,7 +73,7 @@ export async function solveChallengeOnPage(
72
73
  await gotoBestEffort(page, challengeUrl ?? qwenUrl("/"));
73
74
 
74
75
  try {
75
- return await solveBaxiaCaptcha(page, { ...solverOptions, waitForMs });
76
+ return await solveVisibleCaptcha(page, { ...solverOptions, waitForMs });
76
77
  } finally {
77
78
  // Never leave the account page parked on the punish document: a stale
78
79
  // challenge page makes the next detection pass find itself.
@@ -92,6 +93,12 @@ export async function solveChallengeOnPage(
92
93
  * a punish document that is never rendered; opening that document in the page
93
94
  * is what gives the solver a slider to drive.
94
95
  */
96
+ export function isCaptchaRecoveryInBackoff(accountId: string | undefined): boolean {
97
+ if (!accountId) return false;
98
+ const lastFailure = lastFailedRecoveryAt.get(accountId) ?? 0;
99
+ return Date.now() - lastFailure < FAILED_RECOVERY_BACKOFF_MS;
100
+ }
101
+
95
102
  export async function recoverBaxiaCaptcha(
96
103
  accountId: string | undefined,
97
104
  label: string,
@@ -126,19 +133,42 @@ export async function recoverBaxiaCaptcha(
126
133
 
127
134
  activeCaptchaRecoveries.add(accountId);
128
135
  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 {
136
+ const { hasActiveBrowserStreamsForAccount } = await import("./qwen.ts");
137
+ const hasConcurrentStream = hasActiveBrowserStreamsForAccount(accountId);
138
+
136
139
  // recoverOnTimeout: false ensures that if the captcha solve times out,
137
140
  // withAccountPage does NOT destructively kill the browser context, which
138
141
  // previously caused "Target page, context or browser has been closed" mid-slider.
139
142
  const solved = await withAccountPage(
140
143
  accountId,
141
- (page) => solveChallengeOnPage(page, challengeUrl),
144
+ async (page) => {
145
+ // If another stream is actively running on this account's primary page,
146
+ // NEVER navigate `page` away (which would unload its DOM and kill the stream).
147
+ // Instead, try in-place modal solve first, or open an ephemeral tab in the
148
+ // same BrowserContext so cookies/x5sec are solved without touching `page`.
149
+ if (hasConcurrentStream) {
150
+ const solverOptions = {
151
+ maxAttempts: config.captcha.maxAttempts,
152
+ retryDelayMs: config.captcha.retryDelayMs,
153
+ settleMs: config.captcha.settleMs,
154
+ sliderTimeoutMs: config.captcha.sliderTimeoutMs,
155
+ };
156
+ if (await solveVisibleCaptcha(page, { ...solverOptions, waitForMs: 0 })) {
157
+ return true;
158
+ }
159
+ const ctx = typeof page.context === "function" ? page.context() : null;
160
+ if (ctx && typeof ctx.newPage === "function") {
161
+ const tempPage = await ctx.newPage();
162
+ try {
163
+ return await solveChallengeOnPage(tempPage, challengeUrl);
164
+ } finally {
165
+ await tempPage.close({ runBeforeUnload: false }).catch(() => {});
166
+ }
167
+ }
168
+ return false;
169
+ }
170
+ return await solveChallengeOnPage(page, challengeUrl);
171
+ },
142
172
  solverOperationTimeoutMs,
143
173
  solverOperationTimeoutMs,
144
174
  false,