qwenproxy-cli 1.0.21 → 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.21",
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
 
@@ -1294,12 +1298,19 @@ export async function initPlaywrightForAccount(
1294
1298
  );
1295
1299
  return;
1296
1300
  }
1301
+ const existingInit = inFlightAccountInits.get(account.id);
1302
+ if (existingInit) {
1303
+ await existingInit;
1304
+ return;
1305
+ }
1297
1306
 
1298
- const release = await acquireAccountMutex(
1299
- account.id,
1300
- `init:${account.id.substring(0, 12)}`,
1301
- );
1302
- 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 {
1303
1314
  // Double-check after acquiring lock
1304
1315
  if (accountPages.has(account.id)) {
1305
1316
  return;
@@ -1485,8 +1496,16 @@ export async function initPlaywrightForAccount(
1485
1496
  cleanupPlaywrightAccountState(account.id);
1486
1497
  throw error;
1487
1498
  }
1499
+ } finally {
1500
+ release();
1501
+ }
1502
+ })();
1503
+
1504
+ inFlightAccountInits.set(account.id, initPromise);
1505
+ try {
1506
+ await initPromise;
1488
1507
  } finally {
1489
- release();
1508
+ inFlightAccountInits.delete(account.id);
1490
1509
  }
1491
1510
  }
1492
1511
 
@@ -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(">")) {