qwenproxy-cli 1.0.18 → 1.0.19

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.18",
3
+ "version": "1.0.19",
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/api/server.ts CHANGED
@@ -721,27 +721,32 @@ export async function startServer(options?: {
721
721
 
722
722
  // Warm reserve account in background for fast failover without delaying startup
723
723
  if (config.playwright.maxActiveContexts > 1 && remainingAccounts.length > 0) {
724
- const reserveAccount = remainingAccounts[0];
725
- accountsToValidate = remainingAccounts.slice(1);
726
- try {
727
- const ok = await prepareAccountRuntime(
728
- reserveAccount,
729
- getAccountCredentials,
730
- initPlaywrightForAccount,
731
- disableNativeTools,
732
- warmQwenChatPool,
733
- );
734
- if (ok) {
735
- ensureAccountInPriority(reserveAccount.id);
736
- console.log(
737
- `✅ [Server] Reserve account ready (2/${totalAccounts}): ${maskEmail(reserveAccount.email)}`,
724
+ let reserveCandidateIdx = 0;
725
+ for (; reserveCandidateIdx < remainingAccounts.length; reserveCandidateIdx++) {
726
+ const reserveAccount = remainingAccounts[reserveCandidateIdx];
727
+ try {
728
+ const ok = await prepareAccountRuntime(
729
+ reserveAccount,
730
+ getAccountCredentials,
731
+ initPlaywrightForAccount,
732
+ disableNativeTools,
733
+ warmQwenChatPool,
734
+ );
735
+ if (ok) {
736
+ ensureAccountInPriority(reserveAccount.id);
737
+ console.log(
738
+ `✅ [Server] Reserve account ready (2/${totalAccounts}): ${maskEmail(reserveAccount.email)}`,
739
+ );
740
+ reserveCandidateIdx++;
741
+ break;
742
+ }
743
+ } catch (err) {
744
+ console.warn(
745
+ `⚠️ [Server] Failed to warm reserve account ${maskEmail(reserveAccount.email)}: ${getErrorMessage(err)}`,
738
746
  );
739
747
  }
740
- } catch (err) {
741
- console.warn(
742
- `⚠️ [Server] Failed to warm reserve account ${maskEmail(reserveAccount.email)}: ${getErrorMessage(err)}`,
743
- );
744
748
  }
749
+ accountsToValidate = remainingAccounts.slice(reserveCandidateIdx);
745
750
  }
746
751
 
747
752
  let validated = 0;
@@ -279,9 +279,11 @@ export async function saveStorageState(
279
279
  `storageState timed out after ${timeoutMs}ms`,
280
280
  );
281
281
  } catch (error) {
282
- console.warn(
283
- `[Playwright] Failed to save storage state for ${accountId}: ${getErrorMessage(error)}`,
284
- );
282
+ if (!isPlaywrightAlreadyClosedError(error)) {
283
+ console.warn(
284
+ `[Playwright] Failed to save storage state for ${accountId}: ${getErrorMessage(error)}`,
285
+ );
286
+ }
285
287
  }
286
288
  }
287
289
 
@@ -302,6 +304,37 @@ async function hasValidAuthCookie(context: BrowserContext, timeoutMs = 3_000): P
302
304
  }
303
305
  }
304
306
 
307
+ /**
308
+ * Detect whether the page is authenticated.
309
+ * Probes the authoritative /api/v1/auths/ endpoint from the page context (verified via HAR forensics)
310
+ * and falls back to inspecting the presence of visible "Log in" / "Sign up" buttons.
311
+ */
312
+ export async function isPageLoggedIn(page: Page): Promise<boolean> {
313
+ if (!page) return false;
314
+ if (typeof page.isClosed === "function" && page.isClosed()) return false;
315
+ try {
316
+ const url = typeof page.url === "function" ? page.url() : "";
317
+ if (url.includes("/auth") || url.includes("/login")) return false;
318
+ if (typeof page.evaluate !== "function") return true;
319
+
320
+ return await page
321
+ .evaluate(async () => {
322
+ try {
323
+ const res = await fetch("/api/v1/auths/", { method: "GET" });
324
+ return res.status === 200;
325
+ } catch {
326
+ const btn = document.querySelector(
327
+ ".header-right-auth-button, button.header-right-auth-button, a[href*='/auth'], a[href*='/login']",
328
+ );
329
+ return !btn || (btn as HTMLElement).offsetWidth === 0;
330
+ }
331
+ })
332
+ .catch(() => false);
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
337
+
305
338
  export async function getOrLaunchSharedBrowser(
306
339
  browserType: BrowserType = "chromium",
307
340
  headless = true,
@@ -1365,17 +1398,24 @@ export async function initPlaywrightForAccount(
1365
1398
  waitUntil: "domcontentloaded",
1366
1399
  timeout: config.timeouts.navigation,
1367
1400
  });
1368
- const url = acctPage.url();
1369
- if (url.includes("auth") || url.includes("login")) {
1401
+ const loggedIn = await isPageLoggedIn(acctPage);
1402
+ if (!loggedIn) {
1370
1403
  if (account.email && account.password) {
1371
1404
  console.warn(
1372
1405
  `⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1373
1406
  );
1374
- await loginToQwen(account.id, account.email, account.password);
1407
+ const ok = await loginToQwen(account.id, account.email, account.password);
1408
+ if (!ok) {
1409
+ validationError = new Error(
1410
+ `Session expired for ${maskEmail(account.email)} and re-authentication failed`,
1411
+ );
1412
+ continue;
1413
+ }
1375
1414
  } else {
1376
- console.warn(
1377
- `[Playwright] Session expired for account ${account.id} but no credentials available.`,
1415
+ validationError = new Error(
1416
+ `Session expired for account ${account.id} but no credentials available for re-login (run 'qpx login')`,
1378
1417
  );
1418
+ break;
1379
1419
  }
1380
1420
  }
1381
1421
  validationError = null;
@@ -1396,8 +1436,6 @@ export async function initPlaywrightForAccount(
1396
1436
  );
1397
1437
  throw validationError;
1398
1438
  }
1399
-
1400
- // Capture headers by navigating and intercepting
1401
1439
  await captureQwenHeaders(account.id);
1402
1440
 
1403
1441
  // Header capture may leave the UI on a generated chat page. Return the
@@ -1418,7 +1456,7 @@ export async function initPlaywrightForAccount(
1418
1456
 
1419
1457
  touchAccountActivity(account.id);
1420
1458
  } catch (error) {
1421
- await closePlaywrightContextBestEffort(account.id, acctContext);
1459
+ await closePlaywrightContextBestEffort(account.id, acctContext, { skipStorageSave: true });
1422
1460
  cleanupPlaywrightAccountState(account.id);
1423
1461
  throw error;
1424
1462
  }
@@ -1528,23 +1566,20 @@ export async function validateAccountLogin(
1528
1566
  } finally {
1529
1567
  accountPages.delete(account.id);
1530
1568
  }
1531
- } else if (hasAuthCookie) {
1532
- // Validate session by navigating to chat page
1569
+ } else {
1570
+ // Validate session by navigating to chat page and checking login state
1533
1571
  try {
1534
1572
  await acctPage.goto(qwenUrl("/"), {
1535
1573
  waitUntil: "domcontentloaded",
1536
1574
  timeout: config.timeouts.navigation,
1537
1575
  });
1538
- const url = acctPage.url();
1539
- if (url.includes("auth") || url.includes("login")) {
1540
- loggedIn = false;
1541
- if (account.email && account.password) {
1542
- accountPages.set(account.id, acctPage);
1543
- try {
1544
- loggedIn = await loginToQwen(account.id, account.email, account.password);
1545
- } finally {
1546
- accountPages.delete(account.id);
1547
- }
1576
+ loggedIn = await isPageLoggedIn(acctPage);
1577
+ if (!loggedIn && account.email && account.password) {
1578
+ accountPages.set(account.id, acctPage);
1579
+ try {
1580
+ loggedIn = await loginToQwen(account.id, account.email, account.password);
1581
+ } finally {
1582
+ accountPages.delete(account.id);
1548
1583
  }
1549
1584
  }
1550
1585
  } catch {
@@ -1946,59 +1981,64 @@ export async function captureQwenHeaders(
1946
1981
  touchAccountActivity(accountId);
1947
1982
  const cache = getHeaderCache(accountId);
1948
1983
 
1949
- return new Promise<void>((resolve, reject) => {
1950
- let settled = false;
1951
- let routeRegistered = false;
1952
- let timeout: ReturnType<typeof setTimeout> | undefined;
1953
- let routeHandler: (route: any, request: any) => Promise<void>;
1954
- let sawIncompleteHeaders = false;
1955
- let headersCaptured = false;
1956
- let retriggerRequested = false;
1957
- let lastAttemptGraceTimedOut = false;
1958
- let graceTimeoutCount = 0;
1959
- let wakeTriggerLoop: (() => void) | undefined;
1960
- const deadline = Date.now() + timeoutMs;
1961
- const remainingBudgetMs = () => deadline - Date.now();
1962
-
1963
- const cleanupRoute = () => {
1964
- if (!routeRegistered) return;
1965
- void page
1966
- .unroute("**/api/v2/chat/completions*", routeHandler)
1967
- .catch(() => {});
1968
- };
1984
+ let cleanupRoute = async () => {};
1985
+ try {
1986
+ return await new Promise<void>((resolve, reject) => {
1987
+ let settled = false;
1988
+ let routeRegistered = false;
1989
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1990
+ let routeHandler: (route: any, request: any) => Promise<void>;
1991
+ let sawIncompleteHeaders = false;
1992
+ let headersCaptured = false;
1993
+ let retriggerRequested = false;
1994
+ let lastAttemptGraceTimedOut = false;
1995
+ let graceTimeoutCount = 0;
1996
+ let wakeTriggerLoop: (() => void) | undefined;
1997
+ const deadline = Date.now() + timeoutMs;
1998
+ const remainingBudgetMs = () => deadline - Date.now();
1999
+
2000
+ cleanupRoute = async () => {
2001
+ if (!routeRegistered) return;
2002
+ routeRegistered = false;
2003
+ if (!page.isClosed()) {
2004
+ try {
2005
+ await page.unroute("**/api/v2/chat/completions*", routeHandler);
2006
+ } catch {}
2007
+ }
2008
+ };
1969
2009
 
1970
- const wakeTrigger = () => {
1971
- const wake = wakeTriggerLoop;
1972
- wakeTriggerLoop = undefined;
1973
- wake?.();
1974
- };
2010
+ const wakeTrigger = () => {
2011
+ const wake = wakeTriggerLoop;
2012
+ wakeTriggerLoop = undefined;
2013
+ wake?.();
2014
+ };
1975
2015
 
1976
- const settle = (error?: Error) => {
1977
- if (settled) return;
1978
- settled = true;
1979
- if (timeout) clearTimeout(timeout);
1980
- cleanupRoute();
1981
- // A trigger loop parked between attempts has to be released, otherwise it
1982
- // stays pending forever behind an already-settled capture.
1983
- wakeTrigger();
1984
- // When a trigger grace period expired (page fired no completion request),
1985
- // log the OUTCOME so the operator can see whether the retry loop
1986
- // recovered or the account is being rotated into cooldown — the bare
1987
- // per-attempt warning leaves that dangling.
1988
- if (graceTimeoutCount > 0) {
1989
- if (headersCaptured) {
1990
- console.log(
1991
- `✅ [Playwright] Header capture recovered for ${accountId} after ${graceTimeoutCount} silent send(s)`,
1992
- );
1993
- } else {
1994
- console.warn(
1995
- `❌ [Playwright] Header capture failed for ${accountId} after ${graceTimeoutCount} silent send(s): ${error?.message ?? "no completion request"}`,
1996
- );
2016
+ const settle = (error?: Error) => {
2017
+ if (settled) return;
2018
+ settled = true;
2019
+ if (timeout) clearTimeout(timeout);
2020
+ void cleanupRoute();
2021
+ // A trigger loop parked between attempts has to be released, otherwise it
2022
+ // stays pending forever behind an already-settled capture.
2023
+ wakeTrigger();
2024
+ // When a trigger grace period expired (page fired no completion request),
2025
+ // log the OUTCOME so the operator can see whether the retry loop
2026
+ // recovered or the account is being rotated into cooldown — the bare
2027
+ // per-attempt warning leaves that dangling.
2028
+ if (graceTimeoutCount > 0) {
2029
+ if (headersCaptured) {
2030
+ console.log(
2031
+ `✅ [Playwright] Header capture recovered for ${accountId} after ${graceTimeoutCount} silent send(s)`,
2032
+ );
2033
+ } else {
2034
+ console.warn(
2035
+ `❌ [Playwright] Header capture failed for ${accountId} after ${graceTimeoutCount} silent send(s): ${error?.message ?? "no completion request"}`,
2036
+ );
2037
+ }
1997
2038
  }
1998
- }
1999
- if (error) reject(error);
2000
- else resolve();
2001
- };
2039
+ if (error) reject(error);
2040
+ else resolve();
2041
+ };
2002
2042
 
2003
2043
  const incompleteHeadersError = () =>
2004
2044
  new Error(
@@ -2159,8 +2199,8 @@ export async function captureQwenHeaders(
2159
2199
  // burn every trigger attempt on a textarea that does not exist. Re-login
2160
2200
  // immediately when credentials are available; otherwise fail fast with a
2161
2201
  // clear diagnosis instead of 3 pointless grace timeouts.
2162
- const currentUrl = page.url();
2163
- if (currentUrl.includes("/auth") || currentUrl.includes("/login")) {
2202
+ const loggedIn = await isPageLoggedIn(page);
2203
+ if (!loggedIn) {
2164
2204
  const { getAccountCredentials } = await import("../core/accounts.ts");
2165
2205
  const creds = getAccountCredentials(accountId);
2166
2206
  if (creds && creds.email && creds.password) {
@@ -2183,7 +2223,7 @@ export async function captureQwenHeaders(
2183
2223
  } else {
2184
2224
  settle(
2185
2225
  new Error(
2186
- `Header capture failed for ${accountId}: session expired and no credentials available for re-login`,
2226
+ `Header capture failed for ${accountId}: session expired and no credentials available for re-login (run 'qpx login')`,
2187
2227
  ),
2188
2228
  );
2189
2229
  return;
@@ -2327,11 +2367,12 @@ export async function captureQwenHeaders(
2327
2367
  : new Error(`Header capture route registration failed for ${accountId}`),
2328
2368
  );
2329
2369
  });
2330
- });
2370
+ });
2371
+ } finally {
2372
+ await cleanupRoute();
2373
+ }
2331
2374
  }
2332
-
2333
2375
  type CookieSnapshot = Awaited<ReturnType<BrowserContext["cookies"]>>;
2334
-
2335
2376
  /**
2336
2377
  * Fetch the account context cookies once. The snapshot feeds every validity
2337
2378
  * check and the cookie string build, avoiding repeated CDP round-trips.
@@ -3029,15 +3070,23 @@ function cleanupPlaywrightAccountState(accountId: string): void {
3029
3070
  async function closePlaywrightContextBestEffort(
3030
3071
  accountId: string,
3031
3072
  context: BrowserContext,
3073
+ options?: { skipStorageSave?: boolean },
3032
3074
  ): Promise<void> {
3033
- try {
3034
- if (await hasValidAuthCookie(context)) {
3035
- await saveStorageState(context, accountId);
3036
- }
3037
- } catch {}
3075
+ if (!options?.skipStorageSave) {
3076
+ try {
3077
+ if (await hasValidAuthCookie(context)) {
3078
+ await saveStorageState(context, accountId);
3079
+ }
3080
+ } catch {}
3081
+ }
3038
3082
 
3039
3083
  try {
3040
3084
  const pages = context.pages();
3085
+ for (const page of pages) {
3086
+ if (!page.isClosed()) {
3087
+ await (page as any).unrouteAll?.({ behavior: "ignoreErrors" }).catch(() => {});
3088
+ }
3089
+ }
3041
3090
  await Promise.all(
3042
3091
  pages.map((page) =>
3043
3092
  withTimeout(