qwenproxy-cli 1.6.4 → 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.
@@ -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 (no successful tool calls)
613
- const allToolsFailed = toolParser && toolParser.getMalformedToolCalls().length > 0 && toolCallsOut.length === 0;
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.getMalformedToolCalls();
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 (undeclaredNames.length > 0) {
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=malformed_tool_calls`);
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 allToolsFailed =
2168
- toolParser.getMalformedToolCalls().length > 0 &&
2169
- toolParser.getSuccessfulToolCallCount() === 0;
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 (undeclaredNames.length > 0) {
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=malformed_tool_calls`,
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: (chatRes.usage as any).completion_tokens_details?.reasoning_tokens ?? 0,
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 ?? snapshot.estimatedContextTokens)
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[] = [];
@@ -517,7 +518,7 @@ const headerCaches = new Map<string, AccountHeaderCache>();
517
518
  // across the browser session; cookies are dynamically updated from the browser context.
518
519
  const HEADER_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
519
520
  const HEADER_REFRESH_THRESHOLD = 0.8; // Background refresh at 80% of TTL
520
- const COOKIE_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
521
+ const COOKIE_CACHE_TTL = 60 * 1000; // 1 minute (prevents returning stale expiring token after refresh)
521
522
  const cookieCaches = new Map<string, { cookie: string; timestamp: number }>();
522
523
  const lastAccountActivity = new Map<string, number>();
523
524
  const lastKeepAliveNavigation = new Map<string, number>();
@@ -1777,6 +1778,26 @@ export async function initPlaywrightForAccount(
1777
1778
  }
1778
1779
  }
1779
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
+ }
1780
1801
  return;
1781
1802
  }
1782
1803
 
@@ -2160,7 +2181,7 @@ export async function startEmailOtpLogin(
2160
2181
  email: normalizedEmail,
2161
2182
  requestId,
2162
2183
  version: getQwenWebVersion() || "0.3.12",
2163
- timezone: new Date().toString().split(" (")[0],
2184
+ timezone: formatAsciiTimezone(),
2164
2185
  },
2165
2186
  ).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
2166
2187
 
@@ -2272,7 +2293,7 @@ export async function verifyEmailOtpLogin(
2272
2293
  code: cleanCode,
2273
2294
  requestId,
2274
2295
  version: getQwenWebVersion() || "0.3.12",
2275
- timezone: new Date().toString().split(" (")[0],
2296
+ timezone: formatAsciiTimezone(),
2276
2297
  },
2277
2298
  ).catch((err: any) => ({ ok: false, error: err?.message || String(err) }));
2278
2299
 
@@ -2626,7 +2647,7 @@ async function loginViaApi(
2626
2647
 
2627
2648
  const evalRes = await page
2628
2649
  .evaluate(
2629
- async ({ email, password, signinUrl, requestId }) => {
2650
+ async ({ email, password, signinUrl, requestId, tz }) => {
2630
2651
  try {
2631
2652
  const response = await fetch(signinUrl, {
2632
2653
  method: "POST",
@@ -2638,7 +2659,7 @@ async function loginViaApi(
2638
2659
  source: "web",
2639
2660
  version: "0.3.11",
2640
2661
  "x-request-origin": "https://chat.qwen.ai",
2641
- timezone: new Date().toString().split(" (")[0],
2662
+ timezone: tz,
2642
2663
  "x-request-id": requestId,
2643
2664
  },
2644
2665
  body: JSON.stringify({ email, password }),
@@ -2649,7 +2670,7 @@ async function loginViaApi(
2649
2670
  return { ok: false, error: e.message };
2650
2671
  }
2651
2672
  },
2652
- { email, password: hashedPassword, signinUrl, requestId },
2673
+ { email, password: hashedPassword, signinUrl, requestId, tz: formatAsciiTimezone() },
2653
2674
  )
2654
2675
  .catch(() => null);
2655
2676
 
@@ -2667,7 +2688,7 @@ async function loginViaApi(
2667
2688
  const fallbackUrl = qwenUrl("/api/v2/auths/signin");
2668
2689
  const fallbackRes = await page
2669
2690
  .evaluate(
2670
- async ({ email, password, fallbackUrl, requestId }) => {
2691
+ async ({ email, password, fallbackUrl, requestId, tz }) => {
2671
2692
  try {
2672
2693
  const response = await fetch(fallbackUrl, {
2673
2694
  method: "POST",
@@ -2678,7 +2699,7 @@ async function loginViaApi(
2678
2699
  "content-type": "application/json",
2679
2700
  source: "web",
2680
2701
  version: "0.3.11",
2681
- timezone: new Date().toString().split(" (")[0],
2702
+ timezone: tz,
2682
2703
  "x-request-id": requestId,
2683
2704
  },
2684
2705
  body: JSON.stringify({ email, password }),
@@ -2689,7 +2710,7 @@ async function loginViaApi(
2689
2710
  return { ok: false, error: e.message };
2690
2711
  }
2691
2712
  },
2692
- { email, password: hashedPassword, fallbackUrl, requestId },
2713
+ { email, password: hashedPassword, fallbackUrl, requestId, tz: formatAsciiTimezone() },
2693
2714
  )
2694
2715
  .catch(() => null);
2695
2716
 
@@ -3111,7 +3132,7 @@ export async function refreshAccountToken(
3111
3132
  if (refreshToken) {
3112
3133
  try {
3113
3134
  const refreshResult = await page.evaluate(
3114
- async ({ rTok }) => {
3135
+ async ({ rTok, tz }) => {
3115
3136
  try {
3116
3137
  const response = await fetch("https://auth.qwen.ai/api/v2/auths/refresh", {
3117
3138
  method: "GET",
@@ -3121,7 +3142,7 @@ export async function refreshAccountToken(
3121
3142
  source: "web",
3122
3143
  version: "0.3.11",
3123
3144
  "x-request-origin": "https://chat.qwen.ai",
3124
- timezone: new Date().toString().split(" (")[0],
3145
+ timezone: tz,
3125
3146
  authorization: `Bearer ${rTok}`,
3126
3147
  },
3127
3148
  });
@@ -3131,7 +3152,7 @@ export async function refreshAccountToken(
3131
3152
  return { ok: false, error: e.message };
3132
3153
  }
3133
3154
  },
3134
- { rTok: refreshToken },
3155
+ { rTok: refreshToken, tz: formatAsciiTimezone() },
3135
3156
  );
3136
3157
 
3137
3158
  const json = refreshResult?.data;
@@ -3175,6 +3196,7 @@ export async function refreshAccountToken(
3175
3196
  const cache = getHeaderCache(accountId);
3176
3197
  cache.headers.cookie = cookieStr;
3177
3198
  cache.lastRefresh = Date.now();
3199
+ cookieCaches.set(accountId, { cookie: cookieStr, timestamp: Date.now() });
3178
3200
  markAccountHeadersReady(accountId);
3179
3201
 
3180
3202
  // Update SQLite session with new tokens
@@ -3213,6 +3235,7 @@ export async function refreshAccountToken(
3213
3235
  const cache = getHeaderCache(accountId);
3214
3236
  cache.headers.cookie = cookieStr;
3215
3237
  cache.lastRefresh = Date.now();
3238
+ cookieCaches.set(accountId, { cookie: cookieStr, timestamp: Date.now() });
3216
3239
  markAccountHeadersReady(accountId);
3217
3240
  saveAuthSession(accountId, {
3218
3241
  cookie: cookieStr,
@@ -4653,11 +4676,11 @@ export async function keepAlivePlaywrightAccount(
4653
4676
 
4654
4677
  // Proactive session check: if token expires within 5 minutes, refresh in-page silently using refreshAccountToken
4655
4678
  const lastRefreshAttempt = lastProactiveTokenRefresh.get(accountId) ?? 0;
4656
- const cookie = await getCookies(accountId);
4679
+ const cookie = await getCookies(accountId, true);
4657
4680
  if (cookie && isTokenExpiringSoon(cookie, 5) && now - lastRefreshAttempt > 60_000) {
4658
4681
  lastProactiveTokenRefresh.set(accountId, now);
4659
- console.log(
4660
- `💓 [SessionKeeper] Account ${accountId} access token expiring soon (<5m); silently refreshing...`,
4682
+ logger.debug(
4683
+ `[SessionKeeper] Account ${accountId} access token expiring soon (<5m); silently refreshing...`,
4661
4684
  );
4662
4685
  const res = await refreshAccountToken(accountId);
4663
4686
  if (res.success) {
@@ -4665,6 +4688,9 @@ export async function keepAlivePlaywrightAccount(
4665
4688
  touchAccountActivity(accountId);
4666
4689
  return true;
4667
4690
  }
4691
+ logger.warn(
4692
+ `[SessionKeeper] Failed to silently refresh expiring token for ${accountId}: ${res.error || "unknown error"}`,
4693
+ );
4668
4694
  }
4669
4695
 
4670
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 || new Date().toString().split(" (")[0],
83
- Timezone: opts.extra?.Timezone || opts.extra?.timezone || new Date().toString().split(" (")[0],
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":
@@ -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
- active: number;
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 = { active: 0, queue: [] };
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.active -= 1;
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
- slots.active += 1;
645
- next();
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
- async function acquireAccountStreamLock(accountId: string): Promise<() => void> {
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.active < streamSlotCapacity()) {
653
- slots.active += 1;
654
- return Promise.resolve(createStreamSlotRelease(slots));
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(createStreamSlotRelease(slots)));
710
+ slots.queue.push((releaseFn) => resolve(releaseFn));
658
711
  });
659
712
  }
660
713
 
@@ -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
- return Object.fromEntries(
851
- Object.entries(headers).filter(([name]) =>
852
- browserAllowedHeaders.has(name.toLowerCase()),
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: new Date().toString().split(" (")[0],
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: new Date().toString().split(" (")[0],
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: new Date().toString().split(" (")[0],
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: new Date().toString().split(" (")[0],
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: new Date().toString().split(" (")[0],
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: new Date().toString(),
2481
+ timezone: formatAsciiTimezone(),
2425
2482
  },
2426
2483
  }),
2427
2484
  undefined,
@@ -0,0 +1,88 @@
1
+ /**
2
+ * tool-call-heuristics.ts — Heuristics for deciding whether a turn that produced
3
+ * NO (successful) tool call should be auto-retried.
4
+ *
5
+ * The existing retry path only fires when a `<tool_call>` block was emitted
6
+ * but could not be parsed (malformed / undeclared). It does NOT cover the case
7
+ * where the model emits no tool-call block at all and instead answers with
8
+ * prose that references tools as if they were missing, e.g.:
9
+ *
10
+ * "Tool bash does not exists."
11
+ * "Tool read does not exists."
12
+ *
13
+ * In that case the client sees `finish_reason: "stop"` with no `tool_calls`,
14
+ * ends its turn, and the loop stalls until a human nudges it ("continue").
15
+ */
16
+
17
+ export type MissingToolCallReason = "malformed" | "no_call" | "json_in_text" | null;
18
+
19
+ export interface MissingToolCallInput {
20
+ /** Number of tool definitions declared on the request. */
21
+ declaredToolCount: number;
22
+ /** Tool calls that parsed AND resolved to a declared tool. */
23
+ successfulToolCallCount: number;
24
+ /** `<tool_call>` blocks captured but rejected (bad JSON / undeclared name). */
25
+ malformedToolCallCount: number;
26
+ /** Total tool-call blocks the parser saw (malformed + capped + valid). */
27
+ emittedToolCallCount: number;
28
+ /** Assistant-visible text for the turn (excluding the tool-call blocks). */
29
+ text: string;
30
+ }
31
+
32
+ export interface MissingToolCallDecision {
33
+ retry: boolean;
34
+ reason: MissingToolCallReason;
35
+ }
36
+
37
+ /**
38
+ * Matches a model reply that talks about a tool as if it did not exist, which
39
+ * is the signature of the "lost tool definitions under long context" failure.
40
+ * Kept deliberately narrow to avoid retrying legitimate prose.
41
+ */
42
+ export function isToolHesitationText(text: string): boolean {
43
+ if (!text) return false;
44
+ return /\btools?\b[^.\n]{0,24}?\b(?:does\s*n[o']?t|do\s*n[o']?t|does\s*not|do\s*not)\b[^.\n]{0,12}?\bexist/i.test(
45
+ text,
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Detects when the model dumped a raw tool-call JSON payload as prose instead
51
+ * of emitting it as a structured tool call.
52
+ */
53
+ export function isRawToolCallJsonInText(text: string): boolean {
54
+ if (!text) return false;
55
+ const hasToolCallJson =
56
+ /\{\s*"name"\s*:\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*,\s*"arguments"\s*:\s*\{/i.test(text);
57
+ const hasStrayCloseTag = /<\/tool_call>/.test(text);
58
+ return hasToolCallJson || hasStrayCloseTag;
59
+ }
60
+
61
+ export function decideMissingToolCallRetry(
62
+ input: MissingToolCallInput,
63
+ ): MissingToolCallDecision {
64
+ const {
65
+ declaredToolCount,
66
+ successfulToolCallCount,
67
+ malformedToolCallCount,
68
+ emittedToolCallCount,
69
+ text,
70
+ } = input;
71
+
72
+ // No tools were declared: a text answer is the expected outcome.
73
+ if (declaredToolCount <= 0) return { retry: false, reason: null };
74
+
75
+ // A usable call was produced: nothing to recover.
76
+ if (successfulToolCallCount > 0) return { retry: false, reason: null };
77
+
78
+ // Existing behaviour: a block was emitted but it was rejected.
79
+ if (malformedToolCallCount > 0) return { retry: true, reason: "malformed" };
80
+
81
+ // New behaviour: no `<tool_call>` block was emitted at all, yet the model
82
+ // talked about tools as missing (the "does not exists" case).
83
+ if (emittedToolCallCount === 0 && isToolHesitationText(text)) {
84
+ return { retry: true, reason: "no_call" };
85
+ }
86
+
87
+ return { retry: false, reason: null };
88
+ }