qwenproxy-cli 1.6.3 → 1.6.5
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/dist/clean-cache.js +76 -12
- package/dist/delete-chats.js +182 -49
- package/dist/index.js +245 -64
- package/dist/login.js +90 -19
- package/dist/reset-cooldowns.js +79 -13
- package/package.json +1 -1
- package/src/core/mutex.ts +4 -0
- package/src/core/version.ts +1 -1
- package/src/routes/anthropic/translate.ts +15 -7
- package/src/routes/chat/helpers.ts +4 -2
- package/src/routes/chat/retry-policy.ts +3 -1
- package/src/routes/chat/streaming.ts +32 -10
- package/src/routes/responses/adapter.ts +4 -1
- package/src/services/context-meter.ts +1 -1
- package/src/services/playwright.ts +55 -21
- package/src/services/qwen-headers.ts +3 -2
- package/src/services/qwen.ts +88 -31
- package/src/tools/tool-call-heuristics.ts +88 -0
- package/src/utils/timezone.ts +56 -0
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
import type { Message, OpenAIRequest, Usage } from "../../utils/types.ts";
|
|
34
34
|
import { touchChatLock } from "./account.ts";
|
|
35
35
|
import { StreamingToolParser } from "../../tools/parser.ts";
|
|
36
|
+
import {
|
|
37
|
+
decideMissingToolCallRetry,
|
|
38
|
+
type MissingToolCallReason,
|
|
39
|
+
} from "../../tools/tool-call-heuristics.ts";
|
|
36
40
|
import {
|
|
37
41
|
getStream,
|
|
38
42
|
markStreamEmitted,
|
|
@@ -609,8 +613,17 @@ export async function processNonStreamingResponse(
|
|
|
609
613
|
|
|
610
614
|
const finishReason = toolCallsOut.length ? "tool_calls" : "stop";
|
|
611
615
|
|
|
612
|
-
// Auto-retry if all tool calls were malformed
|
|
613
|
-
const
|
|
616
|
+
// Auto-retry if all tool calls were malformed or model hesitated with missing call
|
|
617
|
+
const retryDecision = toolParser
|
|
618
|
+
? decideMissingToolCallRetry({
|
|
619
|
+
declaredToolCount: declaredTools.length,
|
|
620
|
+
successfulToolCallCount: toolParser.getSuccessfulToolCallCount(),
|
|
621
|
+
malformedToolCallCount: toolParser.getMalformedToolCalls().length,
|
|
622
|
+
emittedToolCallCount: toolParser.getEmittedToolCallCount(),
|
|
623
|
+
text: finalContent,
|
|
624
|
+
})
|
|
625
|
+
: { retry: false, reason: null as MissingToolCallReason };
|
|
626
|
+
const allToolsFailed = retryDecision.retry;
|
|
614
627
|
const malformedRetryCount = midStreamRetry?.malformedRetryCount ?? 0;
|
|
615
628
|
if (
|
|
616
629
|
allToolsFailed &&
|
|
@@ -618,7 +631,7 @@ export async function processNonStreamingResponse(
|
|
|
618
631
|
midStreamRetry &&
|
|
619
632
|
malformedRetryCount < config.retry.autoRetryMalformedToolsMax
|
|
620
633
|
) {
|
|
621
|
-
const malformedCalls = toolParser
|
|
634
|
+
const malformedCalls = toolParser?.getMalformedToolCalls() ?? [];
|
|
622
635
|
const malformedCount = malformedCalls.length;
|
|
623
636
|
|
|
624
637
|
// Build detailed error message with available tools list
|
|
@@ -634,7 +647,9 @@ export async function processNonStreamingResponse(
|
|
|
634
647
|
: "";
|
|
635
648
|
|
|
636
649
|
let errorMessage: string;
|
|
637
|
-
if (
|
|
650
|
+
if (retryDecision.reason === "no_call") {
|
|
651
|
+
errorMessage = `You did not emit any tool call. Respond with a single tool-call block using one of the declared tools.${toolsHint}`;
|
|
652
|
+
} else if (undeclaredNames.length > 0) {
|
|
638
653
|
errorMessage = `Your previous ${malformedCount} tool call(s) used undeclared tool names: ${undeclaredNames.join(", ")}. Only declared tools can be executed. Please retry with valid tool names.${toolsHint}`;
|
|
639
654
|
} else {
|
|
640
655
|
const previews = malformedCalls
|
|
@@ -719,7 +734,7 @@ export async function processNonStreamingResponse(
|
|
|
719
734
|
);
|
|
720
735
|
}
|
|
721
736
|
|
|
722
|
-
console.log(`🔄 [Chat] Auto-retry (${malformedRetryCount + 1}/${config.retry.autoRetryMalformedToolsMax}) | ${newStreamResult.activeAccountLabel} | ${body.model} | chat=${newStreamResult.uiSessionId.substring(0, 12)} | reason
|
|
737
|
+
console.log(`🔄 [Chat] Auto-retry (${malformedRetryCount + 1}/${config.retry.autoRetryMalformedToolsMax}) | ${newStreamResult.activeAccountLabel} | ${body.model} | chat=${newStreamResult.uiSessionId.substring(0, 12)} | reason=${retryDecision.reason === "no_call" ? "missing_tool_call" : "malformed_tool_calls"}`);
|
|
723
738
|
|
|
724
739
|
// Process the new stream. Propagate the retry context with an incremented
|
|
725
740
|
// counter so a subsequent malformed response can retry again up to
|
|
@@ -2164,9 +2179,14 @@ export async function processStreamingResponse(
|
|
|
2164
2179
|
|
|
2165
2180
|
while (malformedRetryCount < maxMalformedRetries) {
|
|
2166
2181
|
if (!toolParser) break;
|
|
2167
|
-
const
|
|
2168
|
-
|
|
2169
|
-
toolParser.getSuccessfulToolCallCount()
|
|
2182
|
+
const retryDecision = decideMissingToolCallRetry({
|
|
2183
|
+
declaredToolCount: declaredTools.length,
|
|
2184
|
+
successfulToolCallCount: toolParser.getSuccessfulToolCallCount(),
|
|
2185
|
+
malformedToolCallCount: toolParser.getMalformedToolCalls().length,
|
|
2186
|
+
emittedToolCallCount: toolParser.getEmittedToolCallCount(),
|
|
2187
|
+
text: finalContent,
|
|
2188
|
+
});
|
|
2189
|
+
const allToolsFailed = retryDecision.retry;
|
|
2170
2190
|
if (!allToolsFailed) break;
|
|
2171
2191
|
|
|
2172
2192
|
const malformedCalls = toolParser.getMalformedToolCalls();
|
|
@@ -2185,7 +2205,9 @@ export async function processStreamingResponse(
|
|
|
2185
2205
|
: "";
|
|
2186
2206
|
|
|
2187
2207
|
let errorMessage: string;
|
|
2188
|
-
if (
|
|
2208
|
+
if (retryDecision.reason === "no_call") {
|
|
2209
|
+
errorMessage = `You did not emit any tool call. Respond with a single tool-call block using one of the declared tools.${toolsHint}`;
|
|
2210
|
+
} else if (undeclaredNames.length > 0) {
|
|
2189
2211
|
errorMessage = `Your previous ${malformedCount} tool call(s) used undeclared tool names: ${undeclaredNames.join(", ")}. Only declared tools can be executed. Please retry with valid tool names.${toolsHint}`;
|
|
2190
2212
|
} else {
|
|
2191
2213
|
const previews = malformedCalls
|
|
@@ -2270,7 +2292,7 @@ export async function processStreamingResponse(
|
|
|
2270
2292
|
}
|
|
2271
2293
|
|
|
2272
2294
|
console.log(
|
|
2273
|
-
`🔄 [Chat] Auto-retry (${malformedRetryCount + 1}/${maxMalformedRetries}) | ${newStreamResult.activeAccountLabel} | ${body.model} | chat=${newStreamResult.uiSessionId.substring(0, 12)} | reason
|
|
2295
|
+
`🔄 [Chat] Auto-retry (${malformedRetryCount + 1}/${maxMalformedRetries}) | ${newStreamResult.activeAccountLabel} | ${body.model} | chat=${newStreamResult.uiSessionId.substring(0, 12)} | reason=${retryDecision.reason === "no_call" ? "missing_tool_call" : "malformed_tool_calls"}`,
|
|
2274
2296
|
);
|
|
2275
2297
|
|
|
2276
2298
|
// Transfer the retry's stream registry entry to the original
|
|
@@ -372,7 +372,10 @@ export function chatCompletionsToResponses(
|
|
|
372
372
|
cached_tokens: (chatRes.usage as any).prompt_tokens_details?.cached_tokens ?? 0,
|
|
373
373
|
},
|
|
374
374
|
output_tokens_details: {
|
|
375
|
-
reasoning_tokens:
|
|
375
|
+
reasoning_tokens:
|
|
376
|
+
(chatRes.usage as any).completion_tokens_details?.reasoning_tokens ??
|
|
377
|
+
(chatRes.usage as any).output_tokens_details?.reasoning_tokens ??
|
|
378
|
+
0,
|
|
376
379
|
},
|
|
377
380
|
...(chatRes.usage.context_meter
|
|
378
381
|
? { context_meter: chatRes.usage.context_meter }
|
|
@@ -189,7 +189,7 @@ export function enrichUsageWithContextMeter(
|
|
|
189
189
|
? usage.prompt_tokens
|
|
190
190
|
: null;
|
|
191
191
|
const reportedPromptTokens = options.reportUsage
|
|
192
|
-
? (upstreamPromptTokens ??
|
|
192
|
+
? (upstreamPromptTokens ?? usage.prompt_tokens)
|
|
193
193
|
: usage.prompt_tokens;
|
|
194
194
|
const contextMeter: ContextMeterSnapshot = {
|
|
195
195
|
...snapshot,
|
|
@@ -83,6 +83,7 @@ import { setWafContextResetListener } from "../core/waf-isolation.ts";
|
|
|
83
83
|
import { updateQwenWebVersion, getQwenWebVersion } from "./qwen-headers.ts";
|
|
84
84
|
import { getAccountProfilePath, getProfilesDir } from "../core/paths.ts";
|
|
85
85
|
import { parseJwtExpiry, isTokenExpiringSoon } from "../utils/jwt.ts";
|
|
86
|
+
import { formatAsciiTimezone } from "../utils/timezone.ts";
|
|
86
87
|
|
|
87
88
|
type ContextInitHook = (context: BrowserContext) => Promise<void> | void;
|
|
88
89
|
const contextInitHooks: ContextInitHook[] = [];
|
|
@@ -166,7 +167,7 @@ export function buildChromiumLaunchArgs(viewport: {
|
|
|
166
167
|
const ACCOUNT_MUTEX_MAX_HOLD_MS = 180_000;
|
|
167
168
|
const accountMutexes = new Map<string, Mutex>();
|
|
168
169
|
|
|
169
|
-
function getAccountMutex(accountId: string): Mutex {
|
|
170
|
+
export function getAccountMutex(accountId: string): Mutex {
|
|
170
171
|
let mutex = accountMutexes.get(accountId);
|
|
171
172
|
if (!mutex) {
|
|
172
173
|
mutex = new Mutex(`playwright:${accountId.substring(0, 8)}`, ACCOUNT_MUTEX_MAX_HOLD_MS);
|
|
@@ -189,15 +190,20 @@ async function recoverStuckAccountMutex(
|
|
|
189
190
|
const lockState = mutex.state();
|
|
190
191
|
// A lock is only considered stuck if it has been held for at least PLAYWRIGHT_MUTEX_WAIT_MS (60s).
|
|
191
192
|
// A waiter with a short timeout (e.g. 5s) timing out does NOT mean the lock holder is stuck!
|
|
192
|
-
// Furthermore, never nuke an account while it is initializing (init: takes 20-30s)
|
|
193
|
-
//
|
|
193
|
+
// Furthermore, never nuke an account while it is initializing (init: takes 20-30s),
|
|
194
|
+
// capturing/refreshing headers (headers/refresh can take up to HEADERS_TIMEOUT),
|
|
195
|
+
// running page operations, or while it is actively serving a stream to a user.
|
|
196
|
+
const minLockTimeout = Math.max(PLAYWRIGHT_MUTEX_WAIT_MS, config.timeouts.headers);
|
|
194
197
|
if (
|
|
195
|
-
lockState.heldForMs <
|
|
198
|
+
lockState.heldForMs < minLockTimeout ||
|
|
196
199
|
lockState.heldBy.startsWith("init:") ||
|
|
200
|
+
lockState.heldBy.startsWith("headers:") ||
|
|
201
|
+
lockState.heldBy.startsWith("refresh:") ||
|
|
202
|
+
lockState.heldBy.startsWith("profile-reset:") ||
|
|
197
203
|
isAccountServingStream(accountId)
|
|
198
204
|
) {
|
|
199
205
|
logger.warn(
|
|
200
|
-
`[Playwright] Skipping destructive mutex recovery | account=${accountId} | heldBy=${lockState.heldBy} | heldFor=${lockState.heldForMs}ms | limit=${
|
|
206
|
+
`[Playwright] Skipping destructive mutex recovery | account=${accountId} | heldBy=${lockState.heldBy} | heldFor=${lockState.heldForMs}ms | limit=${minLockTimeout}ms | servingStream=${isAccountServingStream(accountId)}`,
|
|
201
207
|
);
|
|
202
208
|
return;
|
|
203
209
|
}
|
|
@@ -512,7 +518,7 @@ const headerCaches = new Map<string, AccountHeaderCache>();
|
|
|
512
518
|
// across the browser session; cookies are dynamically updated from the browser context.
|
|
513
519
|
const HEADER_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
514
520
|
const HEADER_REFRESH_THRESHOLD = 0.8; // Background refresh at 80% of TTL
|
|
515
|
-
const COOKIE_CACHE_TTL =
|
|
521
|
+
const COOKIE_CACHE_TTL = 60 * 1000; // 1 minute (prevents returning stale expiring token after refresh)
|
|
516
522
|
const cookieCaches = new Map<string, { cookie: string; timestamp: number }>();
|
|
517
523
|
const lastAccountActivity = new Map<string, number>();
|
|
518
524
|
const lastKeepAliveNavigation = new Map<string, number>();
|
|
@@ -1246,6 +1252,7 @@ export async function getBasicHeaders(accountId: string): Promise<{
|
|
|
1246
1252
|
const release = await acquireAccountMutex(
|
|
1247
1253
|
accountId,
|
|
1248
1254
|
`headers:${accountId.substring(0, 12)}`,
|
|
1255
|
+
Math.max(PLAYWRIGHT_MUTEX_WAIT_MS, config.timeouts.headers + 30_000),
|
|
1249
1256
|
);
|
|
1250
1257
|
try {
|
|
1251
1258
|
touchAccountActivity(accountId);
|
|
@@ -1369,8 +1376,10 @@ export async function getBasicHeaders(accountId: string): Promise<{
|
|
|
1369
1376
|
|
|
1370
1377
|
// Refresh headers if stale. A valid cached set remains usable when a
|
|
1371
1378
|
// browser recapture transiently fails; a cold/partial cache must fail.
|
|
1379
|
+
let refreshedInThisCall = false;
|
|
1372
1380
|
if (headersAge > HEADER_CACHE_TTL && !cache.refreshInProgress) {
|
|
1373
1381
|
try {
|
|
1382
|
+
refreshedInThisCall = true;
|
|
1374
1383
|
await refreshHeadersInternal(accountId);
|
|
1375
1384
|
} catch (error) {
|
|
1376
1385
|
if (!hadUsableHeaders) throw error;
|
|
@@ -1443,7 +1452,7 @@ export async function getBasicHeaders(accountId: string): Promise<{
|
|
|
1443
1452
|
} catch {}
|
|
1444
1453
|
}
|
|
1445
1454
|
|
|
1446
|
-
if (!hasRequiredQwenHeaders(cache.headers)) {
|
|
1455
|
+
if (!hasRequiredQwenHeaders(cache.headers) && !refreshedInThisCall) {
|
|
1447
1456
|
console.log(
|
|
1448
1457
|
`🔄 [Playwright] Missing required anti-bot headers for ${accountId}, triggering header interception...`,
|
|
1449
1458
|
);
|
|
@@ -1769,6 +1778,26 @@ export async function initPlaywrightForAccount(
|
|
|
1769
1778
|
}
|
|
1770
1779
|
}
|
|
1771
1780
|
}
|
|
1781
|
+
|
|
1782
|
+
// If the session restored from SQLite has an expired access token (e.g. was in standby for hours/days),
|
|
1783
|
+
// proactively refresh it in-page now with the 30-day refresh_token instead of waiting for the first chat
|
|
1784
|
+
// request to hit an upstream 401 and trigger a slow re-auth / captcha loop.
|
|
1785
|
+
const currentCache = getHeaderCache(account.id);
|
|
1786
|
+
const currentCookie = currentCache?.headers?.cookie || "";
|
|
1787
|
+
if (currentCookie && isTokenExpiringSoon(currentCookie, 5)) {
|
|
1788
|
+
try {
|
|
1789
|
+
const refreshRes = await refreshAccountToken(account.id);
|
|
1790
|
+
if (refreshRes.success) {
|
|
1791
|
+
console.log(
|
|
1792
|
+
`⚡ [Playwright] Silently refreshed expired token restored from DB for ${maskEmail(account.email)}`,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
} catch (err: any) {
|
|
1796
|
+
console.warn(
|
|
1797
|
+
`⚠️ [Playwright] Failed to proactively refresh expired token from DB for ${maskEmail(account.email)}: ${err?.message || err}`,
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1772
1801
|
return;
|
|
1773
1802
|
}
|
|
1774
1803
|
|
|
@@ -2152,7 +2181,7 @@ export async function startEmailOtpLogin(
|
|
|
2152
2181
|
email: normalizedEmail,
|
|
2153
2182
|
requestId,
|
|
2154
2183
|
version: getQwenWebVersion() || "0.3.12",
|
|
2155
|
-
timezone:
|
|
2184
|
+
timezone: formatAsciiTimezone(),
|
|
2156
2185
|
},
|
|
2157
2186
|
).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
|
|
2158
2187
|
|
|
@@ -2264,7 +2293,7 @@ export async function verifyEmailOtpLogin(
|
|
|
2264
2293
|
code: cleanCode,
|
|
2265
2294
|
requestId,
|
|
2266
2295
|
version: getQwenWebVersion() || "0.3.12",
|
|
2267
|
-
timezone:
|
|
2296
|
+
timezone: formatAsciiTimezone(),
|
|
2268
2297
|
},
|
|
2269
2298
|
).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
|
|
2270
2299
|
|
|
@@ -2618,7 +2647,7 @@ async function loginViaApi(
|
|
|
2618
2647
|
|
|
2619
2648
|
const evalRes = await page
|
|
2620
2649
|
.evaluate(
|
|
2621
|
-
async ({ email, password, signinUrl, requestId }) => {
|
|
2650
|
+
async ({ email, password, signinUrl, requestId, tz }) => {
|
|
2622
2651
|
try {
|
|
2623
2652
|
const response = await fetch(signinUrl, {
|
|
2624
2653
|
method: "POST",
|
|
@@ -2630,7 +2659,7 @@ async function loginViaApi(
|
|
|
2630
2659
|
source: "web",
|
|
2631
2660
|
version: "0.3.11",
|
|
2632
2661
|
"x-request-origin": "https://chat.qwen.ai",
|
|
2633
|
-
timezone:
|
|
2662
|
+
timezone: tz,
|
|
2634
2663
|
"x-request-id": requestId,
|
|
2635
2664
|
},
|
|
2636
2665
|
body: JSON.stringify({ email, password }),
|
|
@@ -2641,7 +2670,7 @@ async function loginViaApi(
|
|
|
2641
2670
|
return { ok: false, error: e.message };
|
|
2642
2671
|
}
|
|
2643
2672
|
},
|
|
2644
|
-
{ email, password: hashedPassword, signinUrl, requestId },
|
|
2673
|
+
{ email, password: hashedPassword, signinUrl, requestId, tz: formatAsciiTimezone() },
|
|
2645
2674
|
)
|
|
2646
2675
|
.catch(() => null);
|
|
2647
2676
|
|
|
@@ -2659,7 +2688,7 @@ async function loginViaApi(
|
|
|
2659
2688
|
const fallbackUrl = qwenUrl("/api/v2/auths/signin");
|
|
2660
2689
|
const fallbackRes = await page
|
|
2661
2690
|
.evaluate(
|
|
2662
|
-
async ({ email, password, fallbackUrl, requestId }) => {
|
|
2691
|
+
async ({ email, password, fallbackUrl, requestId, tz }) => {
|
|
2663
2692
|
try {
|
|
2664
2693
|
const response = await fetch(fallbackUrl, {
|
|
2665
2694
|
method: "POST",
|
|
@@ -2670,7 +2699,7 @@ async function loginViaApi(
|
|
|
2670
2699
|
"content-type": "application/json",
|
|
2671
2700
|
source: "web",
|
|
2672
2701
|
version: "0.3.11",
|
|
2673
|
-
timezone:
|
|
2702
|
+
timezone: tz,
|
|
2674
2703
|
"x-request-id": requestId,
|
|
2675
2704
|
},
|
|
2676
2705
|
body: JSON.stringify({ email, password }),
|
|
@@ -2681,7 +2710,7 @@ async function loginViaApi(
|
|
|
2681
2710
|
return { ok: false, error: e.message };
|
|
2682
2711
|
}
|
|
2683
2712
|
},
|
|
2684
|
-
{ email, password: hashedPassword, fallbackUrl, requestId },
|
|
2713
|
+
{ email, password: hashedPassword, fallbackUrl, requestId, tz: formatAsciiTimezone() },
|
|
2685
2714
|
)
|
|
2686
2715
|
.catch(() => null);
|
|
2687
2716
|
|
|
@@ -3103,7 +3132,7 @@ export async function refreshAccountToken(
|
|
|
3103
3132
|
if (refreshToken) {
|
|
3104
3133
|
try {
|
|
3105
3134
|
const refreshResult = await page.evaluate(
|
|
3106
|
-
async ({ rTok }) => {
|
|
3135
|
+
async ({ rTok, tz }) => {
|
|
3107
3136
|
try {
|
|
3108
3137
|
const response = await fetch("https://auth.qwen.ai/api/v2/auths/refresh", {
|
|
3109
3138
|
method: "GET",
|
|
@@ -3113,7 +3142,7 @@ export async function refreshAccountToken(
|
|
|
3113
3142
|
source: "web",
|
|
3114
3143
|
version: "0.3.11",
|
|
3115
3144
|
"x-request-origin": "https://chat.qwen.ai",
|
|
3116
|
-
timezone:
|
|
3145
|
+
timezone: tz,
|
|
3117
3146
|
authorization: `Bearer ${rTok}`,
|
|
3118
3147
|
},
|
|
3119
3148
|
});
|
|
@@ -3123,7 +3152,7 @@ export async function refreshAccountToken(
|
|
|
3123
3152
|
return { ok: false, error: e.message };
|
|
3124
3153
|
}
|
|
3125
3154
|
},
|
|
3126
|
-
{ rTok: refreshToken },
|
|
3155
|
+
{ rTok: refreshToken, tz: formatAsciiTimezone() },
|
|
3127
3156
|
);
|
|
3128
3157
|
|
|
3129
3158
|
const json = refreshResult?.data;
|
|
@@ -3167,6 +3196,7 @@ export async function refreshAccountToken(
|
|
|
3167
3196
|
const cache = getHeaderCache(accountId);
|
|
3168
3197
|
cache.headers.cookie = cookieStr;
|
|
3169
3198
|
cache.lastRefresh = Date.now();
|
|
3199
|
+
cookieCaches.set(accountId, { cookie: cookieStr, timestamp: Date.now() });
|
|
3170
3200
|
markAccountHeadersReady(accountId);
|
|
3171
3201
|
|
|
3172
3202
|
// Update SQLite session with new tokens
|
|
@@ -3205,6 +3235,7 @@ export async function refreshAccountToken(
|
|
|
3205
3235
|
const cache = getHeaderCache(accountId);
|
|
3206
3236
|
cache.headers.cookie = cookieStr;
|
|
3207
3237
|
cache.lastRefresh = Date.now();
|
|
3238
|
+
cookieCaches.set(accountId, { cookie: cookieStr, timestamp: Date.now() });
|
|
3208
3239
|
markAccountHeadersReady(accountId);
|
|
3209
3240
|
saveAuthSession(accountId, {
|
|
3210
3241
|
cookie: cookieStr,
|
|
@@ -4645,11 +4676,11 @@ export async function keepAlivePlaywrightAccount(
|
|
|
4645
4676
|
|
|
4646
4677
|
// Proactive session check: if token expires within 5 minutes, refresh in-page silently using refreshAccountToken
|
|
4647
4678
|
const lastRefreshAttempt = lastProactiveTokenRefresh.get(accountId) ?? 0;
|
|
4648
|
-
const cookie = await getCookies(accountId);
|
|
4679
|
+
const cookie = await getCookies(accountId, true);
|
|
4649
4680
|
if (cookie && isTokenExpiringSoon(cookie, 5) && now - lastRefreshAttempt > 60_000) {
|
|
4650
4681
|
lastProactiveTokenRefresh.set(accountId, now);
|
|
4651
|
-
|
|
4652
|
-
|
|
4682
|
+
logger.debug(
|
|
4683
|
+
`[SessionKeeper] Account ${accountId} access token expiring soon (<5m); silently refreshing...`,
|
|
4653
4684
|
);
|
|
4654
4685
|
const res = await refreshAccountToken(accountId);
|
|
4655
4686
|
if (res.success) {
|
|
@@ -4657,6 +4688,9 @@ export async function keepAlivePlaywrightAccount(
|
|
|
4657
4688
|
touchAccountActivity(accountId);
|
|
4658
4689
|
return true;
|
|
4659
4690
|
}
|
|
4691
|
+
logger.warn(
|
|
4692
|
+
`[SessionKeeper] Failed to silently refresh expiring token for ${accountId}: ${res.error || "unknown error"}`,
|
|
4693
|
+
);
|
|
4660
4694
|
}
|
|
4661
4695
|
|
|
4662
4696
|
const lastNavigation = lastKeepAliveNavigation.get(accountId) ?? 0;
|
|
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid";
|
|
|
2
2
|
import { qwenUrl, qwenOrigin } from "./qwen-url.ts";
|
|
3
3
|
import { config } from "../core/config.js";
|
|
4
4
|
import { getChromeMajor, getSystemLocale } from "./fingerprint.ts";
|
|
5
|
+
import { formatAsciiTimezone } from "../utils/timezone.ts";
|
|
5
6
|
|
|
6
7
|
export function getSystemAcceptLanguage(customLocale?: string): string {
|
|
7
8
|
const locale = customLocale || getSystemLocale();
|
|
@@ -79,8 +80,8 @@ export function buildQwenRequestHeaders(
|
|
|
79
80
|
"bx-v": opts.bxV || "2.5.37",
|
|
80
81
|
source: "web",
|
|
81
82
|
version: opts.version || getQwenWebVersion(),
|
|
82
|
-
timezone: opts.extra?.timezone || opts.extra?.Timezone ||
|
|
83
|
-
Timezone: opts.extra?.Timezone || opts.extra?.timezone ||
|
|
83
|
+
timezone: opts.extra?.timezone || opts.extra?.Timezone || formatAsciiTimezone(),
|
|
84
|
+
Timezone: opts.extra?.Timezone || opts.extra?.timezone || formatAsciiTimezone(),
|
|
84
85
|
// Use the real browser client-hints when captured (anti-hardcoded); fall
|
|
85
86
|
// back to the static fingerprint otherwise.
|
|
86
87
|
"sec-ch-ua":
|
package/src/services/qwen.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { withAccountPage, assertAntiBotHeaders, onBrowserContextCreated, isPlayw
|
|
|
31
31
|
import { recoverBaxiaCaptcha } from "./captcha-coordinator.ts";
|
|
32
32
|
import { startBaxiaCaptchaWatcher } from "./captcha-solver.ts";
|
|
33
33
|
import { isAccountBusy } from "../core/account-concurrency.ts";
|
|
34
|
+
import { formatAsciiTimezone, sanitizeHeaderForFetch } from "../utils/timezone.ts";
|
|
34
35
|
|
|
35
36
|
// Re-exported from extracted modules for backward compatibility
|
|
36
37
|
export {
|
|
@@ -611,18 +612,21 @@ export function buildCapturedQwenHeaders(
|
|
|
611
612
|
// the first's whole generation). FIFO handoff on release keeps the old
|
|
612
613
|
// leak-recovery semantics (release is idempotent; a dropped stream still blocks
|
|
613
614
|
// its slot until cancel/idle-timeout frees it — same as before).
|
|
615
|
+
export const STREAM_SLOT_MAX_HOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
616
|
+
|
|
614
617
|
interface AccountStreamSlots {
|
|
615
|
-
|
|
616
|
-
queue: Array<() => void>;
|
|
618
|
+
activeSlots: Map<number, number>; // slotId -> startedAt
|
|
619
|
+
queue: Array<(releaseFn: () => void) => void>;
|
|
617
620
|
}
|
|
618
621
|
const accountStreamMutexes = new Map<string, AccountStreamSlots>();
|
|
622
|
+
let nextStreamSlotId = 1;
|
|
619
623
|
|
|
620
|
-
function getAccountStreamMutex(
|
|
624
|
+
export function getAccountStreamMutex(
|
|
621
625
|
accountId: string,
|
|
622
626
|
): AccountStreamSlots {
|
|
623
627
|
let slots = accountStreamMutexes.get(accountId);
|
|
624
628
|
if (!slots) {
|
|
625
|
-
slots = {
|
|
629
|
+
slots = { activeSlots: new Map(), queue: [] };
|
|
626
630
|
accountStreamMutexes.set(accountId, slots);
|
|
627
631
|
}
|
|
628
632
|
return slots;
|
|
@@ -632,29 +636,78 @@ function streamSlotCapacity(): number {
|
|
|
632
636
|
return Math.max(1, config.concurrency.maxStreamsPerAccount);
|
|
633
637
|
}
|
|
634
638
|
|
|
635
|
-
function createStreamSlotRelease(slots: AccountStreamSlots): () => void {
|
|
639
|
+
function createStreamSlotRelease(slots: AccountStreamSlots, slotId: number): () => void {
|
|
636
640
|
let released = false;
|
|
637
641
|
return () => {
|
|
638
642
|
if (released) return;
|
|
639
643
|
released = true;
|
|
640
|
-
slots.
|
|
644
|
+
slots.activeSlots.delete(slotId);
|
|
641
645
|
const next = slots.queue.shift();
|
|
642
646
|
if (next) {
|
|
643
647
|
// Hand the slot directly to the waiter (FIFO), same as the old mutex.
|
|
644
|
-
|
|
645
|
-
|
|
648
|
+
const nextId = nextStreamSlotId++;
|
|
649
|
+
slots.activeSlots.set(nextId, Date.now());
|
|
650
|
+
next(createStreamSlotRelease(slots, nextId));
|
|
646
651
|
}
|
|
647
652
|
};
|
|
648
653
|
}
|
|
649
654
|
|
|
650
|
-
|
|
655
|
+
export function sweepStaleAccountStreamSlots(maxHoldMs: number = STREAM_SLOT_MAX_HOLD_MS): number {
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
let sweptCount = 0;
|
|
658
|
+
for (const [accountId, slots] of accountStreamMutexes.entries()) {
|
|
659
|
+
for (const [slotId, startedAt] of Array.from(slots.activeSlots.entries())) {
|
|
660
|
+
if (now - startedAt >= maxHoldMs) {
|
|
661
|
+
logger.warn(
|
|
662
|
+
`[Qwen] Force-releasing stale stream slot | account=${accountId} | slotId=${slotId} | heldFor=${now - startedAt}ms | maxHold=${maxHoldMs}ms`,
|
|
663
|
+
);
|
|
664
|
+
slots.activeSlots.delete(slotId);
|
|
665
|
+
sweptCount++;
|
|
666
|
+
const next = slots.queue.shift();
|
|
667
|
+
if (next) {
|
|
668
|
+
const nextId = nextStreamSlotId++;
|
|
669
|
+
slots.activeSlots.set(nextId, now);
|
|
670
|
+
next(createStreamSlotRelease(slots, nextId));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return sweptCount;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let staleSlotSweeperTimer: NodeJS.Timeout | null = null;
|
|
679
|
+
|
|
680
|
+
export function ensureStaleSlotSweeperStarted(): void {
|
|
681
|
+
if (staleSlotSweeperTimer) return;
|
|
682
|
+
staleSlotSweeperTimer = setInterval(() => {
|
|
683
|
+
try {
|
|
684
|
+
sweepStaleAccountStreamSlots();
|
|
685
|
+
} catch (err) {
|
|
686
|
+
logger.error("[Qwen] Error in stale stream slot sweeper", {
|
|
687
|
+
error: err instanceof Error ? err.message : String(err),
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}, 60_000);
|
|
691
|
+
staleSlotSweeperTimer.unref();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function stopStaleSlotSweeperForTests(): void {
|
|
695
|
+
if (staleSlotSweeperTimer) {
|
|
696
|
+
clearInterval(staleSlotSweeperTimer);
|
|
697
|
+
staleSlotSweeperTimer = null;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export async function acquireAccountStreamLock(accountId: string): Promise<() => void> {
|
|
702
|
+
ensureStaleSlotSweeperStarted();
|
|
651
703
|
const slots = getAccountStreamMutex(accountId);
|
|
652
|
-
if (slots.
|
|
653
|
-
|
|
654
|
-
|
|
704
|
+
if (slots.activeSlots.size < streamSlotCapacity()) {
|
|
705
|
+
const slotId = nextStreamSlotId++;
|
|
706
|
+
slots.activeSlots.set(slotId, Date.now());
|
|
707
|
+
return Promise.resolve(createStreamSlotRelease(slots, slotId));
|
|
655
708
|
}
|
|
656
709
|
return new Promise<() => void>((resolve) => {
|
|
657
|
-
slots.queue.push(() => resolve(
|
|
710
|
+
slots.queue.push((releaseFn) => resolve(releaseFn));
|
|
658
711
|
});
|
|
659
712
|
}
|
|
660
713
|
|
|
@@ -818,7 +871,7 @@ async function withQwenBrowserPage<T>(
|
|
|
818
871
|
return fn(page);
|
|
819
872
|
},
|
|
820
873
|
operationTimeoutMs,
|
|
821
|
-
Math.
|
|
874
|
+
Math.max(60_000, operationTimeoutMs),
|
|
822
875
|
recoverOnTimeout,
|
|
823
876
|
);
|
|
824
877
|
}
|
|
@@ -830,7 +883,7 @@ async function withQwenBrowserPage<T>(
|
|
|
830
883
|
* adds Cookie, User-Agent, Origin, Referer, and sec-* headers, so we only
|
|
831
884
|
* pass the anti-bot tokens and metadata that the browser cannot infer.
|
|
832
885
|
*/
|
|
833
|
-
function getBrowserFetchHeaders(
|
|
886
|
+
export function getBrowserFetchHeaders(
|
|
834
887
|
headers: Record<string, string>,
|
|
835
888
|
): Record<string, string> {
|
|
836
889
|
const browserAllowedHeaders = new Set([
|
|
@@ -847,11 +900,13 @@ function getBrowserFetchHeaders(
|
|
|
847
900
|
"x-accel-buffering",
|
|
848
901
|
]);
|
|
849
902
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
903
|
+
const result: Record<string, string> = {};
|
|
904
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
905
|
+
if (browserAllowedHeaders.has(name.toLowerCase())) {
|
|
906
|
+
result[name] = sanitizeHeaderForFetch(String(value ?? ""));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return result;
|
|
855
910
|
}
|
|
856
911
|
|
|
857
912
|
interface BrowserTextResponse {
|
|
@@ -906,13 +961,14 @@ export async function requestQwenTextInBrowser(
|
|
|
906
961
|
|
|
907
962
|
const evaluateRequest = (page: Page) =>
|
|
908
963
|
page.evaluate(
|
|
909
|
-
async ({ url, method, headers, body, referrer, timeoutMs }: {
|
|
964
|
+
async ({ url, method, headers, body, referrer, timeoutMs, tz }: {
|
|
910
965
|
url: string;
|
|
911
966
|
method: "GET" | "POST" | "DELETE";
|
|
912
967
|
headers: Record<string, string>;
|
|
913
968
|
body?: string;
|
|
914
969
|
referrer?: string;
|
|
915
970
|
timeoutMs: number;
|
|
971
|
+
tz: string;
|
|
916
972
|
}): Promise<BrowserTextResponse> => {
|
|
917
973
|
const controller = new AbortController();
|
|
918
974
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -939,7 +995,7 @@ export async function requestQwenTextInBrowser(
|
|
|
939
995
|
source: "web",
|
|
940
996
|
version: "0.3.11",
|
|
941
997
|
"x-request-origin": "https://chat.qwen.ai",
|
|
942
|
-
timezone:
|
|
998
|
+
timezone: tz,
|
|
943
999
|
authorization: `Bearer ${rTok}`,
|
|
944
1000
|
},
|
|
945
1001
|
signal: AbortSignal.timeout(5000),
|
|
@@ -985,6 +1041,7 @@ export async function requestQwenTextInBrowser(
|
|
|
985
1041
|
body,
|
|
986
1042
|
referrer: options.referrer,
|
|
987
1043
|
timeoutMs: options.timeoutMs ?? Math.min(config.timeouts.page, 20_000),
|
|
1044
|
+
tz: formatAsciiTimezone(),
|
|
988
1045
|
},
|
|
989
1046
|
);
|
|
990
1047
|
const recoverOnTimeout = !options.noMutexRecovery;
|
|
@@ -1931,7 +1988,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1931
1988
|
async (page) => {
|
|
1932
1989
|
const requestId = crypto.randomUUID();
|
|
1933
1990
|
const response = await page.evaluate(
|
|
1934
|
-
async ({ payload, timeoutMs, requestId }: { payload: any; timeoutMs: number; requestId: string }) => {
|
|
1991
|
+
async ({ payload, timeoutMs, requestId, tz }: { payload: any; timeoutMs: number; requestId: string; tz: string }) => {
|
|
1935
1992
|
const controller = new AbortController();
|
|
1936
1993
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1937
1994
|
try {
|
|
@@ -1943,7 +2000,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1943
2000
|
accept: "application/json, text/plain, */*",
|
|
1944
2001
|
"content-type": "application/json",
|
|
1945
2002
|
"x-request-id": requestId,
|
|
1946
|
-
timezone:
|
|
2003
|
+
timezone: tz,
|
|
1947
2004
|
source: "web",
|
|
1948
2005
|
},
|
|
1949
2006
|
body: JSON.stringify(payload),
|
|
@@ -1955,7 +2012,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1955
2012
|
clearTimeout(timeoutId);
|
|
1956
2013
|
}
|
|
1957
2014
|
},
|
|
1958
|
-
{ payload, timeoutMs: config.timeouts.http, requestId },
|
|
2015
|
+
{ payload, timeoutMs: config.timeouts.http, requestId, tz: formatAsciiTimezone() },
|
|
1959
2016
|
);
|
|
1960
2017
|
return response;
|
|
1961
2018
|
},
|
|
@@ -2241,7 +2298,7 @@ export async function deleteSingleQwenChat(
|
|
|
2241
2298
|
const requestHeaders: Record<string, string> = {
|
|
2242
2299
|
source: "web",
|
|
2243
2300
|
version: "0.2.89",
|
|
2244
|
-
timezone:
|
|
2301
|
+
timezone: formatAsciiTimezone(),
|
|
2245
2302
|
"x-request-id": crypto.randomUUID(),
|
|
2246
2303
|
Referer: qwenUrl(`/c/${encodeURIComponent(chatId)}`),
|
|
2247
2304
|
};
|
|
@@ -2294,7 +2351,7 @@ export async function fetchRemoteQwenChats(
|
|
|
2294
2351
|
|
|
2295
2352
|
const requestHeaders: Record<string, string> = {
|
|
2296
2353
|
version: "0.2.89",
|
|
2297
|
-
timezone:
|
|
2354
|
+
timezone: formatAsciiTimezone(),
|
|
2298
2355
|
"x-request-id": crypto.randomUUID(),
|
|
2299
2356
|
Referer: qwenUrl("/settings/chats"),
|
|
2300
2357
|
};
|
|
@@ -2356,7 +2413,7 @@ export async function fetchQwenModels(
|
|
|
2356
2413
|
async (page) => {
|
|
2357
2414
|
const requestId = crypto.randomUUID();
|
|
2358
2415
|
const response = await page.evaluate(
|
|
2359
|
-
async ({ timeoutMs, requestId }: { timeoutMs: number; requestId: string }) => {
|
|
2416
|
+
async ({ timeoutMs, requestId, tz }: { timeoutMs: number; requestId: string; tz: string }) => {
|
|
2360
2417
|
const controller = new AbortController();
|
|
2361
2418
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
2362
2419
|
try {
|
|
@@ -2365,7 +2422,7 @@ export async function fetchQwenModels(
|
|
|
2365
2422
|
headers: {
|
|
2366
2423
|
accept: "application/json, text/plain, */*",
|
|
2367
2424
|
"x-request-id": requestId,
|
|
2368
|
-
timezone:
|
|
2425
|
+
timezone: tz,
|
|
2369
2426
|
source: "web",
|
|
2370
2427
|
},
|
|
2371
2428
|
signal: controller.signal,
|
|
@@ -2375,7 +2432,7 @@ export async function fetchQwenModels(
|
|
|
2375
2432
|
clearTimeout(timeoutId);
|
|
2376
2433
|
}
|
|
2377
2434
|
},
|
|
2378
|
-
{ timeoutMs: config.timeouts.http, requestId },
|
|
2435
|
+
{ timeoutMs: config.timeouts.http, requestId, tz: formatAsciiTimezone() },
|
|
2379
2436
|
);
|
|
2380
2437
|
return response;
|
|
2381
2438
|
},
|
|
@@ -2421,7 +2478,7 @@ export async function fetchQwenModels(
|
|
|
2421
2478
|
bxUa,
|
|
2422
2479
|
bxUmidtoken,
|
|
2423
2480
|
extra: {
|
|
2424
|
-
timezone:
|
|
2481
|
+
timezone: formatAsciiTimezone(),
|
|
2425
2482
|
},
|
|
2426
2483
|
}),
|
|
2427
2484
|
undefined,
|