qwenproxy-cli 1.0.18 → 1.0.20
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 +1 -1
- package/src/api/server.ts +23 -18
- package/src/services/media-generation.ts +4 -21
- package/src/services/playwright.ts +136 -87
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qwenproxy-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.20",
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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;
|
|
@@ -956,18 +956,9 @@ function parseJsonIfPossible(raw: string): unknown {
|
|
|
956
956
|
}
|
|
957
957
|
|
|
958
958
|
/**
|
|
959
|
-
* Cooldown for a RateLimited error.
|
|
960
|
-
*
|
|
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) {
|
|
@@ -279,9 +279,11 @@ export async function saveStorageState(
|
|
|
279
279
|
`storageState timed out after ${timeoutMs}ms`,
|
|
280
280
|
);
|
|
281
281
|
} catch (error) {
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
1369
|
-
if (
|
|
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
|
-
|
|
1377
|
-
`
|
|
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
|
|
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
|
-
|
|
1539
|
-
if (
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2010
|
+
const wakeTrigger = () => {
|
|
2011
|
+
const wake = wakeTriggerLoop;
|
|
2012
|
+
wakeTriggerLoop = undefined;
|
|
2013
|
+
wake?.();
|
|
2014
|
+
};
|
|
1975
2015
|
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
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
|
-
|
|
2000
|
-
|
|
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
|
|
2163
|
-
if (
|
|
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.
|
|
@@ -2518,7 +2559,7 @@ export async function withAccountPage<T>(
|
|
|
2518
2559
|
);
|
|
2519
2560
|
const context = accountContexts.get(accountId);
|
|
2520
2561
|
if (context) {
|
|
2521
|
-
await closePlaywrightContextBestEffort(accountId, context);
|
|
2562
|
+
await closePlaywrightContextBestEffort(accountId, context, { skipStorageSave: true });
|
|
2522
2563
|
}
|
|
2523
2564
|
cleanupPlaywrightAccountState(accountId);
|
|
2524
2565
|
}
|
|
@@ -3006,7 +3047,7 @@ export function installContextDeathHandlers(
|
|
|
3006
3047
|
): void {
|
|
3007
3048
|
const onDeath = (): void => {
|
|
3008
3049
|
cleanupPlaywrightAccountState(accountId);
|
|
3009
|
-
void closePlaywrightContextBestEffort(accountId, context).catch(() => {});
|
|
3050
|
+
void closePlaywrightContextBestEffort(accountId, context, { skipStorageSave: true }).catch(() => {});
|
|
3010
3051
|
};
|
|
3011
3052
|
context.on("close", onDeath);
|
|
3012
3053
|
page.on("crash", onDeath);
|
|
@@ -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
|
-
|
|
3034
|
-
|
|
3035
|
-
await
|
|
3036
|
-
|
|
3037
|
-
|
|
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(
|