qwenproxy-cli 1.0.21 → 1.0.23

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.23",
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, {
@@ -10,32 +10,52 @@ import {
10
10
  closeAllPlaywright,
11
11
  } from "./playwright.ts";
12
12
  import { isAuthMockEnabled } from "./auth-playwright.ts";
13
+ import { maskEmail } from "../core/logger.ts";
13
14
  export interface DeleteChatsResult {
14
15
  attempted: number;
15
16
  succeeded: number;
16
17
  mode: "accounts";
17
18
  }
18
19
 
19
- async function ensurePlaywrightSession(account: QwenAccount): Promise<void> {
20
+ async function ensurePlaywrightSession(
21
+ account: QwenAccount,
22
+ index = 1,
23
+ total = 1,
24
+ ): Promise<void> {
20
25
  if (isPlaywrightInitialized(account.id) || isAuthMockEnabled()) return;
21
26
 
22
27
  const credentials = getAccountCredentials(account.id);
23
28
  if (!credentials) {
24
- throw new Error(`Account ${account.id} credentials not found`);
29
+ throw new Error(`Credenciais da conta ${account.id} não encontradas.`);
25
30
  }
26
31
 
27
32
  console.log(
28
- `[DeleteChats] Initializing Playwright session for ${account.email}...`,
33
+ `[DeleteChats] [${index}/${total}] Abrindo navegador para ${maskEmail(account.email)}...`,
29
34
  );
30
- await initPlaywrightForAccount(credentials);
35
+ await initPlaywrightForAccount(credentials, true, "chromium", {
36
+ skipHeaderCapture: true,
37
+ });
31
38
  console.log(
32
- `✅ [DeleteChats] Playwright session ready for ${account.email}.`,
39
+ `✅ [DeleteChats] [${index}/${total}] Sessão pronta para ${maskEmail(account.email)}.`,
33
40
  );
34
41
  }
35
42
 
36
- export async function deleteChatsForAccount(account: QwenAccount): Promise<boolean> {
37
- await ensurePlaywrightSession(account);
38
- return deleteAllQwenChats(account.id);
43
+ export async function deleteChatsForAccount(
44
+ account: QwenAccount,
45
+ index = 1,
46
+ total = 1,
47
+ ): Promise<boolean> {
48
+ await ensurePlaywrightSession(account, index, total);
49
+ console.log(
50
+ `🗑️ [DeleteChats] [${index}/${total}] Apagando conversas remotas de ${maskEmail(account.email)}...`,
51
+ );
52
+ const ok = await deleteAllQwenChats(account.id);
53
+ if (ok) {
54
+ console.log(
55
+ `✅ [DeleteChats] [${index}/${total}] Conversas apagadas com sucesso para ${maskEmail(account.email)}.`,
56
+ );
57
+ }
58
+ return ok;
39
59
  }
40
60
 
41
61
  export async function deleteChatsForAccountId(accountId: string): Promise<boolean> {
@@ -65,13 +85,16 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
65
85
  let succeeded = 0;
66
86
 
67
87
  try {
68
- for (const account of accounts) {
88
+ for (let i = 0; i < accounts.length; i++) {
89
+ const account = accounts[i];
90
+ const currentIdx = i + 1;
91
+ const totalCount = accounts.length;
69
92
  try {
70
- const ok = await deleteChatsForAccount(account);
93
+ const ok = await deleteChatsForAccount(account, currentIdx, totalCount);
71
94
  if (ok) succeeded++;
72
95
  } catch (error) {
73
96
  console.error(
74
- `[DeleteChats] Failed to delete chats for ${account.email}:`,
97
+ `❌ [DeleteChats] [${currentIdx}/${totalCount}] Falha ao apagar conversas de ${maskEmail(account.email)}:`,
75
98
  error instanceof Error ? error.message : String(error),
76
99
  );
77
100
  }
@@ -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
 
@@ -1286,6 +1290,7 @@ export async function initPlaywrightForAccount(
1286
1290
  rawAccount: QwenAccount,
1287
1291
  headless = true,
1288
1292
  browserType: BrowserType = "chromium",
1293
+ options: { skipHeaderCapture?: boolean } = {},
1289
1294
  ): Promise<void> {
1290
1295
  const account = await resolveAccountCredentials(rawAccount);
1291
1296
  if (accountPages.has(account.id)) {
@@ -1294,12 +1299,19 @@ export async function initPlaywrightForAccount(
1294
1299
  );
1295
1300
  return;
1296
1301
  }
1302
+ const existingInit = inFlightAccountInits.get(account.id);
1303
+ if (existingInit) {
1304
+ await existingInit;
1305
+ return;
1306
+ }
1297
1307
 
1298
- const release = await acquireAccountMutex(
1299
- account.id,
1300
- `init:${account.id.substring(0, 12)}`,
1301
- );
1302
- try {
1308
+ const initPromise = (async () => {
1309
+ const release = await acquireAccountMutex(
1310
+ account.id,
1311
+ `init:${account.id.substring(0, 12)}`,
1312
+ Math.max(PLAYWRIGHT_MUTEX_WAIT_MS, 120_000),
1313
+ );
1314
+ try {
1303
1315
  // Double-check after acquiring lock
1304
1316
  if (accountPages.has(account.id)) {
1305
1317
  return;
@@ -1335,11 +1347,19 @@ export async function initPlaywrightForAccount(
1335
1347
 
1336
1348
  let acctContext: BrowserContext;
1337
1349
  try {
1338
- acctContext = await engine.launchPersistentContext(profilePath, launchOptions);
1350
+ acctContext = await withTimeout(
1351
+ engine.launchPersistentContext(profilePath, launchOptions),
1352
+ 30_000,
1353
+ `O navegador não iniciou em 30s para a conta ${maskEmail(account.email)}. Se o QwenProxy estiver rodando em outro terminal, feche-o antes de executar este comando.`,
1354
+ );
1339
1355
  } catch (launchErr: any) {
1340
1356
  if (launchErr?.message?.includes("Executable doesn't exist")) {
1341
1357
  autoInstallPlaywrightChromium();
1342
- acctContext = await engine.launchPersistentContext(profilePath, launchOptions);
1358
+ acctContext = await withTimeout(
1359
+ engine.launchPersistentContext(profilePath, launchOptions),
1360
+ 30_000,
1361
+ `O navegador não iniciou em 30s para a conta ${maskEmail(account.email)}.`,
1362
+ );
1343
1363
  } else {
1344
1364
  throw launchErr;
1345
1365
  }
@@ -1461,7 +1481,9 @@ export async function initPlaywrightForAccount(
1461
1481
  );
1462
1482
  throw validationError;
1463
1483
  }
1464
- await captureQwenHeaders(account.id);
1484
+ if (!options.skipHeaderCapture) {
1485
+ await captureQwenHeaders(account.id);
1486
+ }
1465
1487
 
1466
1488
  // Header capture may leave the UI on a generated chat page. Return the
1467
1489
  // primary tab to the canonical chat home.
@@ -1485,8 +1507,16 @@ export async function initPlaywrightForAccount(
1485
1507
  cleanupPlaywrightAccountState(account.id);
1486
1508
  throw error;
1487
1509
  }
1510
+ } finally {
1511
+ release();
1512
+ }
1513
+ })();
1514
+
1515
+ inFlightAccountInits.set(account.id, initPromise);
1516
+ try {
1517
+ await initPromise;
1488
1518
  } finally {
1489
- release();
1519
+ inFlightAccountInits.delete(account.id);
1490
1520
  }
1491
1521
  }
1492
1522
 
@@ -1540,11 +1570,19 @@ export async function validateAccountLogin(
1540
1570
 
1541
1571
  let acctContext: BrowserContext;
1542
1572
  try {
1543
- acctContext = await engine.launchPersistentContext(profilePath, launchOptions);
1573
+ acctContext = await withTimeout(
1574
+ engine.launchPersistentContext(profilePath, launchOptions),
1575
+ 30_000,
1576
+ `O navegador não iniciou em 30s para a conta ${maskEmail(account.email)}. Se o QwenProxy estiver rodando em outro terminal, feche-o antes de executar este comando.`,
1577
+ );
1544
1578
  } catch (launchErr: any) {
1545
1579
  if (launchErr?.message?.includes("Executable doesn't exist")) {
1546
1580
  autoInstallPlaywrightChromium();
1547
- acctContext = await engine.launchPersistentContext(profilePath, launchOptions);
1581
+ acctContext = await withTimeout(
1582
+ engine.launchPersistentContext(profilePath, launchOptions),
1583
+ 30_000,
1584
+ `O navegador não iniciou em 30s para a conta ${maskEmail(account.email)}.`,
1585
+ );
1548
1586
  } else {
1549
1587
  throw launchErr;
1550
1588
  }
@@ -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
@@ -1951,14 +1916,31 @@ function formatPublicQwenModel(model: Record<string, unknown>): PublicQwenModel
1951
1916
  }
1952
1917
 
1953
1918
  export async function deleteAllQwenChats(accountId?: string): Promise<boolean> {
1954
- const { headers } = await getQwenHeaders(false, accountId);
1919
+ let requestHeaders: Record<string, string>;
1920
+ if (isAuthMockEnabled()) {
1921
+ const { headers } = await getQwenHeaders(false, accountId);
1922
+ requestHeaders = buildCapturedQwenHeaders(headers, {
1923
+ referer: qwenUrl("/settings/chats"),
1924
+ });
1925
+ } else {
1926
+ // In live mode, requestQwenTextInBrowser executes inside the authenticated
1927
+ // browser page where session cookies are attached automatically.
1928
+ // Bypassing getQwenHeaders avoids triggering captureQwenHeaders (which sends
1929
+ // a dummy chat completion to intercept anti-fraud tokens not needed for deletions).
1930
+ requestHeaders = {
1931
+ source: "web",
1932
+ version: "0.2.89",
1933
+ timezone: new Date().toString().split(" (")[0],
1934
+ "x-request-id": crypto.randomUUID(),
1935
+ Referer: qwenUrl("/settings/chats"),
1936
+ };
1937
+ }
1938
+
1955
1939
  const response = await requestQwenTextInBrowser(
1956
1940
  accountId,
1957
1941
  "DELETE",
1958
1942
  "/api/v2/chats/",
1959
- buildCapturedQwenHeaders(headers, {
1960
- referer: qwenUrl("/settings/chats"),
1961
- }),
1943
+ requestHeaders,
1962
1944
  undefined,
1963
1945
  { referrer: qwenUrl("/settings/chats") },
1964
1946
  );
@@ -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(">")) {