qwenproxy-cli 1.0.20 → 1.0.22

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.20",
3
+ "version": "1.0.22",
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": {
package/src/core/mutex.ts CHANGED
@@ -102,6 +102,16 @@ export class Mutex {
102
102
  this.lockedByKey = "";
103
103
  }
104
104
 
105
+ /**
106
+ * Heartbeat the lock: extend lockedAt while the holder is actively making progress.
107
+ * Prevents premature force-release during long-running streaming generations.
108
+ */
109
+ touch(key?: string): void {
110
+ if (this.locked && (!key || this.lockedByKey === key)) {
111
+ this.lockedAt = Date.now();
112
+ }
113
+ }
114
+
105
115
  /** Returns true if the mutex is not locked and has no waiting queue. */
106
116
  isIdle(): boolean {
107
117
  return !this.locked && this.queue.length === 0;
@@ -112,10 +112,11 @@ const personalizationLocks = new Map<string, Mutex>();
112
112
  mutex = new Mutex(
113
113
  `chat:${chatId.substring(0, 8)}`,
114
114
  // The chat lock is held for the whole stream lifetime. A long
115
- // generation (reasoning + huge context) can legitimately exceed the
116
- // global 120s hold limit; use the same budget as the acquire timeout
117
- // so the force-release never kills a healthy mid-stream turn.
118
- timeoutMs,
115
+ // generation (reasoning + huge context) can legitimately take 4-5 min;
116
+ // use config.timeouts.totalRequestTimeout (600s) as the max hold limit, while
117
+ // touchChatLock extends it on every chunk so active streams are never
118
+ // force-released mid-generation.
119
+ config.timeouts.totalRequestTimeout,
119
120
  );
120
121
  chatLocks.set(chatId, mutex);
121
122
  }
@@ -141,6 +142,12 @@ const personalizationLocks = new Map<string, Mutex>();
141
142
  };
142
143
  }
143
144
 
145
+ export function touchChatLock(chatId: string | undefined): void {
146
+ if (!chatId) return;
147
+ const mutex = chatLocks.get(chatId);
148
+ mutex?.touch();
149
+ }
150
+
144
151
  async function acquirePersonalizationLock(
145
152
  accountId: string,
146
153
  ): Promise<() => void> {
@@ -30,6 +30,7 @@ import {
30
30
  shouldRetryInvalidInputOnSameAccount,
31
31
  } from "./retry-policy.ts";
32
32
  import type { Message, OpenAIRequest, Usage } from "../../utils/types.ts";
33
+ import { touchChatLock } from "./account.ts";
33
34
  import { StreamingToolParser } from "../../tools/parser.ts";
34
35
  import {
35
36
  getStream,
@@ -351,7 +352,7 @@ export async function processNonStreamingResponse(
351
352
  while (true) {
352
353
  const { done, value } = await reader.read();
353
354
  if (done) break;
354
-
355
+ touchChatLock(currentUiSessionId);
355
356
  const decoded = decoder.decode(value, { stream: true });
356
357
  if (!sawSseProtocol) {
357
358
  protocolBuffer += decoded;
@@ -1598,7 +1599,7 @@ export async function processStreamingResponse(
1598
1599
  throw readError;
1599
1600
  }
1600
1601
  if (readResult.done) break;
1601
-
1602
+ touchChatLock(currentUiSessionId);
1602
1603
  buffer += decoder.decode(readResult.value, { stream: true });
1603
1604
  lineEnd = buffer.indexOf("\n");
1604
1605
  if (lineEnd === -1) continue;
@@ -2141,7 +2142,7 @@ export async function processStreamingResponse(
2141
2142
  retryReadLoop: while (true) {
2142
2143
  const { done, value } = await retryReader.read();
2143
2144
  if (done) break;
2144
-
2145
+ touchChatLock(currentUiSessionId);
2145
2146
  retryBuf += retryDecoder.decode(value, { stream: true });
2146
2147
  let lineStart = 0;
2147
2148
  let lineEnd = retryBuf.indexOf("\n", lineStart);
@@ -111,26 +111,27 @@ export async function recoverBaxiaCaptcha(
111
111
 
112
112
  // The slider itself waits up to 5s for each attempt. Keep the page
113
113
  // operation alive for the full bounded solver budget so a slow challenge
114
- // cannot be mistaken for a stuck browser and reset the account context.
114
+ // cannot be mistaken for a stuck browser. Do not clamp to timeouts.page.
115
115
  // Two navigations (open the challenge, return to the chat page) are part of
116
116
  // the recovery, so their budget belongs in the same total.
117
- const solverOperationTimeoutMs = Math.min(
118
- config.timeouts.page,
119
- Math.max(
120
- 15_000,
121
- config.captcha.timeoutMs +
122
- config.captcha.maxAttempts *
123
- (3_000 + config.captcha.retryDelayMs + config.captcha.settleMs) +
124
- 2 * CHALLENGE_NAVIGATION_TIMEOUT_MS,
125
- ),
117
+ const solverOperationTimeoutMs = Math.max(
118
+ 15_000,
119
+ config.captcha.timeoutMs +
120
+ config.captcha.maxAttempts *
121
+ (3_000 + config.captcha.retryDelayMs + config.captcha.settleMs) +
122
+ 2 * CHALLENGE_NAVIGATION_TIMEOUT_MS,
126
123
  );
127
124
 
128
125
  try {
126
+ // recoverOnTimeout: false ensures that if the captcha solve times out,
127
+ // withAccountPage does NOT destructively kill the browser context, which
128
+ // previously caused "Target page, context or browser has been closed" mid-slider.
129
129
  const solved = await withAccountPage(
130
130
  accountId,
131
131
  (page) => solveChallengeOnPage(page, challengeUrl),
132
132
  solverOperationTimeoutMs,
133
133
  Math.min(config.timeouts.page, 5_000),
134
+ false,
134
135
  );
135
136
 
136
137
  metrics.histogram("captcha.solve.duration", Date.now() - startedAt, {
@@ -199,7 +199,11 @@ async function recoverStuckAccountMutex(
199
199
  // A normal request can recover the browser on the next attempt. Avoid
200
200
  // recursively scheduling another reset when the reset/close path itself was
201
201
  // the operation that timed out.
202
- if (!key.startsWith("profile-reset:") && !key.startsWith("close:")) {
202
+ if (
203
+ !key.startsWith("profile-reset:") &&
204
+ !key.startsWith("close:") &&
205
+ !key.startsWith("init:")
206
+ ) {
203
207
  schedulePlaywrightProfileReset(accountId);
204
208
  }
205
209
  }
@@ -232,7 +236,7 @@ async function acquireAccountMutex(
232
236
  const accountContexts = new Map<string, BrowserContext>();
233
237
  const accountPages = new Map<string, Page>();
234
238
  const cachedUserAgents = new Map<string, string>();
235
-
239
+ const inFlightAccountInits = new Map<string, Promise<void>>();
236
240
  let sharedBrowser: Browser | null = null;
237
241
  let sharedBrowserPromise: Promise<Browser> | null = null;
238
242
 
@@ -308,8 +312,17 @@ async function hasValidAuthCookie(context: BrowserContext, timeoutMs = 3_000): P
308
312
  * Detect whether the page is authenticated.
309
313
  * Probes the authoritative /api/v1/auths/ endpoint from the page context (verified via HAR forensics)
310
314
  * and falls back to inspecting the presence of visible "Log in" / "Sign up" buttons.
315
+ *
316
+ * The probe is bounded: page.evaluate ignores Playwright's default timeouts, so a
317
+ * WAF-blocked page whose in-page fetch never settles would hang the caller with no
318
+ * error at all. A probe that cannot answer is treated as "not logged in" — the
319
+ * caller re-authenticates or reloads, which is strictly better than burning the
320
+ * whole header budget waiting on a frozen page.
311
321
  */
312
- export async function isPageLoggedIn(page: Page): Promise<boolean> {
322
+ export async function isPageLoggedIn(
323
+ page: Page,
324
+ timeoutMs = SESSION_PROBE_NAVIGATION_TIMEOUT_MS,
325
+ ): Promise<boolean> {
313
326
  if (!page) return false;
314
327
  if (typeof page.isClosed === "function" && page.isClosed()) return false;
315
328
  try {
@@ -317,7 +330,7 @@ export async function isPageLoggedIn(page: Page): Promise<boolean> {
317
330
  if (url.includes("/auth") || url.includes("/login")) return false;
318
331
  if (typeof page.evaluate !== "function") return true;
319
332
 
320
- return await page
333
+ const probe = page
321
334
  .evaluate(async () => {
322
335
  try {
323
336
  const res = await fetch("/api/v1/auths/", { method: "GET" });
@@ -330,6 +343,12 @@ export async function isPageLoggedIn(page: Page): Promise<boolean> {
330
343
  }
331
344
  })
332
345
  .catch(() => false);
346
+
347
+ return await withTimeout(
348
+ probe,
349
+ Math.max(1_000, timeoutMs),
350
+ `session probe timed out after ${timeoutMs}ms`,
351
+ );
333
352
  } catch {
334
353
  return false;
335
354
  }
@@ -456,6 +475,16 @@ const FIRST_TRIGGER_GRACE_MS = 3_000;
456
475
  * sends cover it while still failing a page that never produces them.
457
476
  */
458
477
  const HEADER_CAPTURE_TRIGGER_ATTEMPTS = 3;
478
+ /**
479
+ * A healthy Qwen chat page renders its input within a couple of seconds. When
480
+ * it never does, the page is blocked (WAF interstitial, punish document, or a
481
+ * cold SPA that failed to hydrate) and page.focus would wait out the 60s page
482
+ * default, freezing the whole capture. Bound the wait well under the header
483
+ * budget so a stuck page reloads and retries instead of hanging.
484
+ */
485
+ const CHAT_INPUT_APPEAR_TIMEOUT_MS = 15_000;
486
+ /** Per-action bound for focus/fill/type once the input is already visible. */
487
+ const CHAT_INPUT_ACTION_TIMEOUT_MS = 10_000;
459
488
 
460
489
  /**
461
490
  * A challenge blocking the chat page makes the send button inert, so header
@@ -1269,12 +1298,19 @@ export async function initPlaywrightForAccount(
1269
1298
  );
1270
1299
  return;
1271
1300
  }
1301
+ const existingInit = inFlightAccountInits.get(account.id);
1302
+ if (existingInit) {
1303
+ await existingInit;
1304
+ return;
1305
+ }
1272
1306
 
1273
- const release = await acquireAccountMutex(
1274
- account.id,
1275
- `init:${account.id.substring(0, 12)}`,
1276
- );
1277
- try {
1307
+ const initPromise = (async () => {
1308
+ const release = await acquireAccountMutex(
1309
+ account.id,
1310
+ `init:${account.id.substring(0, 12)}`,
1311
+ Math.max(PLAYWRIGHT_MUTEX_WAIT_MS, 120_000),
1312
+ );
1313
+ try {
1278
1314
  // Double-check after acquiring lock
1279
1315
  if (accountPages.has(account.id)) {
1280
1316
  return;
@@ -1460,8 +1496,16 @@ export async function initPlaywrightForAccount(
1460
1496
  cleanupPlaywrightAccountState(account.id);
1461
1497
  throw error;
1462
1498
  }
1499
+ } finally {
1500
+ release();
1501
+ }
1502
+ })();
1503
+
1504
+ inFlightAccountInits.set(account.id, initPromise);
1505
+ try {
1506
+ await initPromise;
1463
1507
  } finally {
1464
- release();
1508
+ inFlightAccountInits.delete(account.id);
1465
1509
  }
1466
1510
  }
1467
1511
 
@@ -1992,6 +2036,7 @@ export async function captureQwenHeaders(
1992
2036
  let headersCaptured = false;
1993
2037
  let retriggerRequested = false;
1994
2038
  let lastAttemptGraceTimedOut = false;
2039
+ let lastAttemptInputMissing = false;
1995
2040
  let graceTimeoutCount = 0;
1996
2041
  let wakeTriggerLoop: (() => void) | undefined;
1997
2042
  const deadline = Date.now() + timeoutMs;
@@ -2199,7 +2244,10 @@ export async function captureQwenHeaders(
2199
2244
  // burn every trigger attempt on a textarea that does not exist. Re-login
2200
2245
  // immediately when credentials are available; otherwise fail fast with a
2201
2246
  // clear diagnosis instead of 3 pointless grace timeouts.
2202
- const loggedIn = await isPageLoggedIn(page);
2247
+ const loggedIn = await isPageLoggedIn(
2248
+ page,
2249
+ Math.max(1_000, Math.min(remainingBudgetMs(), SESSION_PROBE_NAVIGATION_TIMEOUT_MS)),
2250
+ );
2203
2251
  if (!loggedIn) {
2204
2252
  const { getAccountCredentials } = await import("../core/accounts.ts");
2205
2253
  const creds = getAccountCredentials(accountId);
@@ -2237,11 +2285,59 @@ export async function captureQwenHeaders(
2237
2285
  // Mirrors upstream 5b3fd3e (robust account header capture).
2238
2286
  const inputSelector =
2239
2287
  '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, "");
2288
+ // Bound the appearance wait: a page that never renders the chat input is
2289
+ // blocked (WAF interstitial, punish document, or failed SPA hydration).
2290
+ // Unbounded, page.focus would burn its 60s default timeout on every
2291
+ // attempt, freezing the whole capture and cooling a healthy account with
2292
+ // AuthInitFailed. A miss marks the attempt for a reload instead.
2293
+ try {
2294
+ await page
2295
+ .locator(inputSelector)
2296
+ .first()
2297
+ .waitFor({
2298
+ state: "visible",
2299
+ timeout: Math.max(
2300
+ 1,
2301
+ Math.min(CHAT_INPUT_APPEAR_TIMEOUT_MS, remainingBudgetMs()),
2302
+ ),
2303
+ });
2304
+ } catch {
2305
+ if (settled || page.isClosed()) return;
2306
+ console.warn(
2307
+ `⏱️ [Playwright] Chat input never appeared for ${accountId} (attempt ${attempt}); reloading`,
2308
+ );
2309
+ lastAttemptInputMissing = true;
2310
+ retriggerRequested = true;
2311
+ wakeTrigger();
2312
+ return;
2313
+ }
2243
2314
  if (settled || page.isClosed()) return;
2244
- await page.type(inputSelector, "a", { delay: 100 });
2315
+ const inputActionTimeoutMs = Math.max(
2316
+ 1,
2317
+ Math.min(CHAT_INPUT_ACTION_TIMEOUT_MS, remainingBudgetMs()),
2318
+ );
2319
+ try {
2320
+ await page.focus(inputSelector, { timeout: inputActionTimeoutMs });
2321
+ if (settled || page.isClosed()) return;
2322
+ await page.fill(inputSelector, "", { timeout: inputActionTimeoutMs });
2323
+ if (settled || page.isClosed()) return;
2324
+ await page.type(inputSelector, "a", {
2325
+ delay: 100,
2326
+ timeout: inputActionTimeoutMs,
2327
+ });
2328
+ } catch {
2329
+ // The input detached mid-interaction (challenge overlay, SPA
2330
+ // re-render): same diagnosis as never appearing — only a fresh load
2331
+ // recovers it.
2332
+ if (settled || page.isClosed()) return;
2333
+ console.warn(
2334
+ `⏱️ [Playwright] Chat input interaction failed for ${accountId} (attempt ${attempt}); reloading`,
2335
+ );
2336
+ lastAttemptInputMissing = true;
2337
+ retriggerRequested = true;
2338
+ wakeTrigger();
2339
+ return;
2340
+ }
2245
2341
  if (settled || page.isClosed()) return;
2246
2342
  await sleep(2000);
2247
2343
  if (settled || page.isClosed()) return;
@@ -2309,8 +2405,16 @@ export async function captureQwenHeaders(
2309
2405
  // no request (indicating a stuck page/challenge that needs a fresh load).
2310
2406
  // Attempt 2 preserves the page from attempt 1 so the bx SDK that just
2311
2407
  // finished initializing in the background is not thrown away.
2312
- if (attempt === 1 || (lastAttemptGraceTimedOut && attempt >= 3)) {
2408
+ // A missing chat input is the exception: the page never rendered the
2409
+ // chat UI, so there is no warm SDK state to protect and only a fresh
2410
+ // load can recover it.
2411
+ if (
2412
+ attempt === 1 ||
2413
+ lastAttemptInputMissing ||
2414
+ (lastAttemptGraceTimedOut && attempt >= 3)
2415
+ ) {
2313
2416
  lastAttemptGraceTimedOut = false;
2417
+ lastAttemptInputMissing = false;
2314
2418
  await openChatPage();
2315
2419
  }
2316
2420
  if (settled) return;
@@ -733,14 +733,15 @@ async function withQwenBrowserPage<T>(
733
733
  operationTimeoutMs = config.timeouts.page,
734
734
  recoverOnTimeout = true,
735
735
  ): Promise<T> {
736
- // Keep the account page on the chat UI for normal browser operations. The
737
- // personalization helper passes /settings/personalization explicitly; an
738
- // omitted target must not leave a same-origin settings page in place.
739
- const effectiveTargetPath = targetPath || "/";
740
- const targetUrl = qwenUrl(effectiveTargetPath);
736
+ // When targetPath is omitted, any page under the Qwen origin can run the
737
+ // in-page evaluate/fetch without an expensive, stream-breaking page.goto.
738
+ // Only navigate when targetPath is explicitly provided or when the page is not
739
+ // on the Qwen origin yet (e.g. about:blank).
740
+ const targetUrl = qwenUrl(targetPath || "/");
741
741
  const targetOrigin = new URL(targetUrl).origin;
742
- const normalizedTargetPath =
743
- new URL(targetUrl).pathname.replace(/\/+$/, "") || "/";
742
+ const normalizedTargetPath = targetPath
743
+ ? new URL(targetUrl).pathname.replace(/\/+$/, "") || "/"
744
+ : null;
744
745
 
745
746
  return withAccountPage(
746
747
  accountId,
@@ -755,10 +756,11 @@ async function withQwenBrowserPage<T>(
755
756
  // Navigate below when the current page has no usable URL.
756
757
  }
757
758
 
758
- if (
759
+ const needsNavigation =
759
760
  currentOrigin !== targetOrigin ||
760
- (normalizedTargetPath && currentPath !== normalizedTargetPath)
761
- ) {
761
+ (normalizedTargetPath !== null && currentPath !== normalizedTargetPath);
762
+
763
+ if (needsNavigation) {
762
764
  await page.goto(targetUrl, {
763
765
  waitUntil: "domcontentloaded",
764
766
  timeout: Math.min(config.timeouts.navigation, operationTimeoutMs),
@@ -773,44 +775,7 @@ async function withQwenBrowserPage<T>(
773
775
  );
774
776
  }
775
777
 
776
- async function withQwenPersonalizationPage<T>(
777
- accountId: string,
778
- fn: (page: Page) => Promise<T>,
779
- operationTimeoutMs = config.timeouts.page,
780
- recoverOnTimeout = true,
781
- ): Promise<T> {
782
- return withQwenBrowserPage(
783
- accountId,
784
- async (page) => {
785
- try {
786
- return await fn(page);
787
- } finally {
788
- if (!page.isClosed()) {
789
- try {
790
- const currentUrl = new URL(page.url());
791
- const currentPath = currentUrl.pathname.replace(/\/+$/, "") || "/";
792
- if (currentUrl.origin !== qwenOrigin() || currentPath !== "/") {
793
- await page.goto(qwenUrl("/"), {
794
- waitUntil: "domcontentloaded",
795
- timeout: Math.min(config.timeouts.navigation, operationTimeoutMs),
796
- });
797
- }
798
- } catch (error) {
799
- // Do not mask the personalization request result if restoring the
800
- // normal chat page fails; the next normal operation will retry it.
801
- logger.warn("[Qwen] Could not restore chat page after personalization", {
802
- accountId,
803
- error: error instanceof Error ? error.message : String(error),
804
- });
805
- }
806
- }
807
- }
808
- },
809
- "/settings/personalization",
810
- operationTimeoutMs,
811
- recoverOnTimeout,
812
- );
813
- }
778
+
814
779
 
815
780
  /**
816
781
  * Build minimal headers for browser-side fetch. The browser automatically
@@ -302,6 +302,14 @@ function advanceMarkdownCodeState(
302
302
  let delimiterLength = initialDelimiterLength;
303
303
 
304
304
  for (let i = 0; i < text.length;) {
305
+ // Inline code spans (1 or 2 backticks) cannot cross line breaks per CommonMark §6.1.
306
+ // Only fenced code blocks (3+ backticks) span multiple lines.
307
+ if (text[i] === "\n" && delimiterLength < 3) {
308
+ delimiterLength = 0;
309
+ i++;
310
+ continue;
311
+ }
312
+
305
313
  if (text[i] !== "`") {
306
314
  i++;
307
315
  continue;
@@ -320,7 +328,6 @@ function advanceMarkdownCodeState(
320
328
 
321
329
  i += runLength;
322
330
  }
323
-
324
331
  return delimiterLength;
325
332
  }
326
333
 
@@ -331,6 +338,13 @@ function findNextToolOpenTagOutsideMarkdownCode(
331
338
  let delimiterLength = initialDelimiterLength;
332
339
 
333
340
  for (let i = 0; i < buffer.length;) {
341
+ // Inline code spans (1 or 2 backticks) cannot cross line breaks per CommonMark §6.1.
342
+ if (buffer[i] === "\n" && delimiterLength < 3) {
343
+ delimiterLength = 0;
344
+ i++;
345
+ continue;
346
+ }
347
+
334
348
  if (buffer[i] === "`") {
335
349
  let runLength = 1;
336
350
  while (i + runLength < buffer.length && buffer[i + runLength] === "`") {
@@ -346,7 +360,6 @@ function findNextToolOpenTagOutsideMarkdownCode(
346
360
  i += runLength;
347
361
  continue;
348
362
  }
349
-
350
363
  if (delimiterLength === 0 && buffer[i] === "<") {
351
364
  const sub = buffer.substring(i);
352
365
  for (const name of getOpenNames()) {
@@ -371,6 +384,13 @@ function findPartialToolOpenIndexOutsideMarkdownCode(
371
384
  const openNames = getOpenNames();
372
385
 
373
386
  for (let i = 0; i < buffer.length;) {
387
+ // Inline code spans (1 or 2 backticks) cannot cross line breaks per CommonMark §6.1.
388
+ if (buffer[i] === "\n" && delimiterLength < 3) {
389
+ delimiterLength = 0;
390
+ i++;
391
+ continue;
392
+ }
393
+
374
394
  if (buffer[i] === "`") {
375
395
  let runLength = 1;
376
396
  while (i + runLength < buffer.length && buffer[i + runLength] === "`") {
@@ -386,7 +406,6 @@ function findPartialToolOpenIndexOutsideMarkdownCode(
386
406
  i += runLength;
387
407
  continue;
388
408
  }
389
-
390
409
  if (delimiterLength === 0 && buffer[i] === "<") {
391
410
  const tailLower = buffer.substring(i).toLowerCase();
392
411
  if (!tailLower.includes(">")) {