qwenproxy-cli 1.0.19 → 1.0.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
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": {
@@ -956,18 +956,9 @@ function parseJsonIfPossible(raw: string): unknown {
956
956
  }
957
957
 
958
958
  /**
959
- * Cooldown for a RateLimited error. Uses the hours the upstream reports in its
960
- * error message (e.g. "Please wait 4 hours...") when present; otherwise falls
961
- * back to the account manager's default cooldown.
959
+ * Cooldown for a RateLimited error. Per Invariant 4, daily quotas reset strictly
960
+ * at 00:00 UTC, so we delegate to markAccountRateLimited's default midnight calculation.
962
961
  */
963
- function rateLimitCooldownMs(err: UpstreamRateLimit): number | undefined {
964
- const hourMatch = err.message?.match(/(\d+)\s*hours?/i);
965
- if (hourMatch) {
966
- const hours = Math.max(1, parseInt(hourMatch[1], 10));
967
- return hours * 60 * 60 * 1000;
968
- }
969
- return undefined;
970
- }
971
962
 
972
963
  /**
973
964
  * Detects a Qwen daily usage limit response. The upstream answers HTTP 200
@@ -1290,11 +1281,7 @@ export async function generateImage(params: {
1290
1281
  }
1291
1282
 
1292
1283
  if (lastError instanceof UpstreamRateLimit) {
1293
- markAccountRateLimited(
1294
- account.id,
1295
- rateLimitCooldownMs(lastError),
1296
- "RateLimited",
1297
- );
1284
+ markAccountRateLimited(account.id, undefined, "RateLimited");
1298
1285
  } else {
1299
1286
  markAccountRateLimited(account.id, ACCOUNT_COOLDOWN_MS, "MediaGenFailed");
1300
1287
  }
@@ -1533,11 +1520,7 @@ export async function generateVideo(params: {
1533
1520
  }
1534
1521
 
1535
1522
  if (lastError instanceof UpstreamRateLimit) {
1536
- markAccountRateLimited(
1537
- account.id,
1538
- rateLimitCooldownMs(lastError),
1539
- "RateLimited",
1540
- );
1523
+ markAccountRateLimited(account.id, undefined, "RateLimited");
1541
1524
  } else {
1542
1525
  markAccountRateLimited(account.id, ACCOUNT_COOLDOWN_MS, "MediaGenFailed");
1543
1526
  }
@@ -189,7 +189,7 @@ async function recoverStuckAccountMutex(
189
189
  );
190
190
  const context = accountContexts.get(accountId);
191
191
  if (context) {
192
- await closePlaywrightContextBestEffort(accountId, context);
192
+ await closePlaywrightContextBestEffort(accountId, context, { skipStorageSave: true });
193
193
  }
194
194
  cleanupPlaywrightAccountState(accountId);
195
195
  if (accountMutexes.get(accountId) === mutex) {
@@ -308,8 +308,17 @@ async function hasValidAuthCookie(context: BrowserContext, timeoutMs = 3_000): P
308
308
  * Detect whether the page is authenticated.
309
309
  * Probes the authoritative /api/v1/auths/ endpoint from the page context (verified via HAR forensics)
310
310
  * and falls back to inspecting the presence of visible "Log in" / "Sign up" buttons.
311
+ *
312
+ * The probe is bounded: page.evaluate ignores Playwright's default timeouts, so a
313
+ * WAF-blocked page whose in-page fetch never settles would hang the caller with no
314
+ * error at all. A probe that cannot answer is treated as "not logged in" — the
315
+ * caller re-authenticates or reloads, which is strictly better than burning the
316
+ * whole header budget waiting on a frozen page.
311
317
  */
312
- export async function isPageLoggedIn(page: Page): Promise<boolean> {
318
+ export async function isPageLoggedIn(
319
+ page: Page,
320
+ timeoutMs = SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
321
+ ): Promise<boolean> {
313
322
  if (!page) return false;
314
323
  if (typeof page.isClosed === "function" && page.isClosed()) return false;
315
324
  try {
@@ -317,7 +326,7 @@ export async function isPageLoggedIn(page: Page): Promise<boolean> {
317
326
  if (url.includes("/auth") || url.includes("/login")) return false;
318
327
  if (typeof page.evaluate !== "function") return true;
319
328
 
320
- return await page
329
+ const probe = page
321
330
  .evaluate(async () => {
322
331
  try {
323
332
  const res = await fetch("/api/v1/auths/", { method: "GET" });
@@ -330,6 +339,12 @@ export async function isPageLoggedIn(page: Page): Promise<boolean> {
330
339
  }
331
340
  })
332
341
  .catch(() => false);
342
+
343
+ return await withTimeout(
344
+ probe,
345
+ Math.max(1_000, timeoutMs),
346
+ `session probe timed out after ${timeoutMs}ms`,
347
+ );
333
348
  } catch {
334
349
  return false;
335
350
  }
@@ -456,6 +471,16 @@ const FIRST_TRIGGER_GRACE_MS = 3_000;
456
471
  * sends cover it while still failing a page that never produces them.
457
472
  */
458
473
  const HEADER_CAPTURE_TRIGGER_ATTEMPTS = 3;
474
+ /**
475
+ * A healthy Qwen chat page renders its input within a couple of seconds. When
476
+ * it never does, the page is blocked (WAF interstitial, punish document, or a
477
+ * cold SPA that failed to hydrate) and page.focus would wait out the 60s page
478
+ * default, freezing the whole capture. Bound the wait well under the header
479
+ * budget so a stuck page reloads and retries instead of hanging.
480
+ */
481
+ const CHAT_INPUT_APPEAR_TIMEOUT_MS = 15_000;
482
+ /** Per-action bound for focus/fill/type once the input is already visible. */
483
+ const CHAT_INPUT_ACTION_TIMEOUT_MS = 10_000;
459
484
 
460
485
  /**
461
486
  * A challenge blocking the chat page makes the send button inert, so header
@@ -1992,6 +2017,7 @@ export async function captureQwenHeaders(
1992
2017
  let headersCaptured = false;
1993
2018
  let retriggerRequested = false;
1994
2019
  let lastAttemptGraceTimedOut = false;
2020
+ let lastAttemptInputMissing = false;
1995
2021
  let graceTimeoutCount = 0;
1996
2022
  let wakeTriggerLoop: (() => void) | undefined;
1997
2023
  const deadline = Date.now() + timeoutMs;
@@ -2199,7 +2225,10 @@ export async function captureQwenHeaders(
2199
2225
  // burn every trigger attempt on a textarea that does not exist. Re-login
2200
2226
  // immediately when credentials are available; otherwise fail fast with a
2201
2227
  // clear diagnosis instead of 3 pointless grace timeouts.
2202
- const loggedIn = await isPageLoggedIn(page);
2228
+ const loggedIn = await isPageLoggedIn(
2229
+ page,
2230
+ Math.max(1_000, Math.min(remainingBudgetMs(), SESSION_PROBE_NAVIGATION_TIMEOUT_MS)),
2231
+ );
2203
2232
  if (!loggedIn) {
2204
2233
  const { getAccountCredentials } = await import("../core/accounts.ts");
2205
2234
  const creds = getAccountCredentials(accountId);
@@ -2237,11 +2266,59 @@ export async function captureQwenHeaders(
2237
2266
  // Mirrors upstream 5b3fd3e (robust account header capture).
2238
2267
  const inputSelector =
2239
2268
  'textarea.message-input-textarea:visible, textarea:visible, [contenteditable="true"]:visible';
2240
- await page.focus(inputSelector);
2241
- if (settled || page.isClosed()) return;
2242
- await page.fill(inputSelector, "");
2269
+ // Bound the appearance wait: a page that never renders the chat input is
2270
+ // blocked (WAF interstitial, punish document, or failed SPA hydration).
2271
+ // Unbounded, page.focus would burn its 60s default timeout on every
2272
+ // attempt, freezing the whole capture and cooling a healthy account with
2273
+ // AuthInitFailed. A miss marks the attempt for a reload instead.
2274
+ try {
2275
+ await page
2276
+ .locator(inputSelector)
2277
+ .first()
2278
+ .waitFor({
2279
+ state: "visible",
2280
+ timeout: Math.max(
2281
+ 1,
2282
+ Math.min(CHAT_INPUT_APPEAR_TIMEOUT_MS, remainingBudgetMs()),
2283
+ ),
2284
+ });
2285
+ } catch {
2286
+ if (settled || page.isClosed()) return;
2287
+ console.warn(
2288
+ `⏱️ [Playwright] Chat input never appeared for ${accountId} (attempt ${attempt}); reloading`,
2289
+ );
2290
+ lastAttemptInputMissing = true;
2291
+ retriggerRequested = true;
2292
+ wakeTrigger();
2293
+ return;
2294
+ }
2243
2295
  if (settled || page.isClosed()) return;
2244
- await page.type(inputSelector, "a", { delay: 100 });
2296
+ const inputActionTimeoutMs = Math.max(
2297
+ 1,
2298
+ Math.min(CHAT_INPUT_ACTION_TIMEOUT_MS, remainingBudgetMs()),
2299
+ );
2300
+ try {
2301
+ await page.focus(inputSelector, { timeout: inputActionTimeoutMs });
2302
+ if (settled || page.isClosed()) return;
2303
+ await page.fill(inputSelector, "", { timeout: inputActionTimeoutMs });
2304
+ if (settled || page.isClosed()) return;
2305
+ await page.type(inputSelector, "a", {
2306
+ delay: 100,
2307
+ timeout: inputActionTimeoutMs,
2308
+ });
2309
+ } catch {
2310
+ // The input detached mid-interaction (challenge overlay, SPA
2311
+ // re-render): same diagnosis as never appearing — only a fresh load
2312
+ // recovers it.
2313
+ if (settled || page.isClosed()) return;
2314
+ console.warn(
2315
+ `⏱️ [Playwright] Chat input interaction failed for ${accountId} (attempt ${attempt}); reloading`,
2316
+ );
2317
+ lastAttemptInputMissing = true;
2318
+ retriggerRequested = true;
2319
+ wakeTrigger();
2320
+ return;
2321
+ }
2245
2322
  if (settled || page.isClosed()) return;
2246
2323
  await sleep(2000);
2247
2324
  if (settled || page.isClosed()) return;
@@ -2309,8 +2386,16 @@ export async function captureQwenHeaders(
2309
2386
  // no request (indicating a stuck page/challenge that needs a fresh load).
2310
2387
  // Attempt 2 preserves the page from attempt 1 so the bx SDK that just
2311
2388
  // finished initializing in the background is not thrown away.
2312
- if (attempt === 1 || (lastAttemptGraceTimedOut && attempt >= 3)) {
2389
+ // A missing chat input is the exception: the page never rendered the
2390
+ // chat UI, so there is no warm SDK state to protect and only a fresh
2391
+ // load can recover it.
2392
+ if (
2393
+ attempt === 1 ||
2394
+ lastAttemptInputMissing ||
2395
+ (lastAttemptGraceTimedOut && attempt >= 3)
2396
+ ) {
2313
2397
  lastAttemptGraceTimedOut = false;
2398
+ lastAttemptInputMissing = false;
2314
2399
  await openChatPage();
2315
2400
  }
2316
2401
  if (settled) return;
@@ -2559,7 +2644,7 @@ export async function withAccountPage<T>(
2559
2644
  );
2560
2645
  const context = accountContexts.get(accountId);
2561
2646
  if (context) {
2562
- await closePlaywrightContextBestEffort(accountId, context);
2647
+ await closePlaywrightContextBestEffort(accountId, context, { skipStorageSave: true });
2563
2648
  }
2564
2649
  cleanupPlaywrightAccountState(accountId);
2565
2650
  }
@@ -3047,7 +3132,7 @@ export function installContextDeathHandlers(
3047
3132
  ): void {
3048
3133
  const onDeath = (): void => {
3049
3134
  cleanupPlaywrightAccountState(accountId);
3050
- void closePlaywrightContextBestEffort(accountId, context).catch(() => {});
3135
+ void closePlaywrightContextBestEffort(accountId, context, { skipStorageSave: true }).catch(() => {});
3051
3136
  };
3052
3137
  context.on("close", onDeath);
3053
3138
  page.on("crash", onDeath);