qwenproxy-cli 1.0.15 → 1.0.17

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.15",
3
+ "version": "1.0.17",
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
@@ -653,16 +653,12 @@ export async function startServer(options?: {
653
653
  const totalAccounts = accounts.length;
654
654
 
655
655
  // Warm accounts in priority order (recently successful accounts first),
656
- // skipping accounts still on cooldown. Warm up to maxActiveContexts
657
- // accounts (default 2: main + reserve) so an immediate failover has a
658
- // live browser ready instead of incurring a cold start.
656
+ // skipping accounts still on cooldown. Warm the primary account first so
657
+ // the server binds the port and goes online immediately (~15-20s).
658
+ // Reserve account(s) and standby validations run seamlessly in background.
659
659
  const warmOrder = getAccountsByPriority(accounts).filter(
660
660
  (account) => !getAccountCooldownInfo(account.id),
661
661
  );
662
- const targetWarmCount = Math.min(
663
- warmOrder.length,
664
- Math.max(1, config.playwright.maxActiveContexts),
665
- );
666
662
  const readyAccountIds = new Set<string>();
667
663
 
668
664
  for (let i = 0; i < warmOrder.length; i++) {
@@ -676,11 +672,9 @@ export async function startServer(options?: {
676
672
  if (ok) {
677
673
  readyAccountIds.add(warmOrder[i].id);
678
674
  console.log(
679
- `✅ [Server] Account ready (${readyAccountIds.size}/${totalAccounts}): ${maskEmail(warmOrder[i].email)}`,
675
+ `✅ [Server] Account ready (1/${totalAccounts}): ${maskEmail(warmOrder[i].email)}`,
680
676
  );
681
- if (readyAccountIds.size >= targetWarmCount) {
682
- break;
683
- }
677
+ break;
684
678
  }
685
679
  }
686
680
 
@@ -717,16 +711,43 @@ export async function startServer(options?: {
717
711
  `🪶 [Server] ${remainingAccounts.length} standby account(s) will initialize on demand`,
718
712
  );
719
713
 
720
- // Validate standby accounts in background: check login, add to priority,
721
- // but keep browser closed until actually needed
714
+ // In background: warm 1 reserve account (if maxActiveContexts > 1) and
715
+ // validate the rest of the standby accounts
722
716
  void (async () => {
723
717
  const { validateAccountLogin } = await import("../services/playwright.ts");
724
718
  const { ensureAccountInPriority } = await import("../core/account-priority.ts");
725
719
 
720
+ let accountsToValidate = remainingAccounts;
721
+
722
+ // Warm reserve account in background for fast failover without delaying startup
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)}`,
738
+ );
739
+ }
740
+ } catch (err) {
741
+ console.warn(
742
+ `⚠️ [Server] Failed to warm reserve account ${maskEmail(reserveAccount.email)}: ${getErrorMessage(err)}`,
743
+ );
744
+ }
745
+ }
746
+
726
747
  let validated = 0;
727
748
  let failed = 0;
728
749
 
729
- for (const account of remainingAccounts) {
750
+ for (const account of accountsToValidate) {
730
751
  try {
731
752
  const creds = getAccountCredentials(account.id) ?? account;
732
753
  // Validate login in background with real unmasked credentials
@@ -757,17 +778,14 @@ export async function startServer(options?: {
757
778
  markAccountRateLimited(
758
779
  account.id,
759
780
  24 * 3600 * 1000,
760
- `AuthFailed: ${getErrorMessage(error)}`,
781
+ `StandbyValidationError: ${getErrorMessage(error)}`,
761
782
  );
762
783
  }
763
784
  }
764
- if (failed > 0) {
765
- console.warn(
766
- `⚠️ [Server] Standby validation finished: ${validated} ok, ${failed} failed`,
767
- );
768
- } else if (validated > 0) {
785
+
786
+ if (validated > 0 || failed > 0) {
769
787
  console.log(
770
- `✅ [Server] Standby validation complete: all ${validated} account(s) ready`,
788
+ `✅ [Server] Standby validation complete: ${validated} account(s) ready${failed > 0 ? `, ${failed} failed` : ""}`,
771
789
  );
772
790
  }
773
791
  })().catch((error) => {
@@ -216,7 +216,10 @@ export function shouldRetryChatInProgressOnSameAccount(
216
216
 
217
217
  export function isAccountInitializationError(err: unknown): boolean {
218
218
  const message = errMessage(err).toLowerCase();
219
+ const code = errCode(err).toLowerCase();
219
220
  return (
221
+ code === "acquire_deadline" ||
222
+ message.includes("acquire deadline") ||
220
223
  message.includes("header capture returned incomplete anti-fraud headers") ||
221
224
  message.includes("required qwen anti-fraud headers are unavailable") ||
222
225
  message.includes("playwright not initialized for account") ||
@@ -18,6 +18,7 @@ import {
18
18
  } from "../../core/reasoning-effort.ts";
19
19
 
20
20
  import { TOOL_CALL_OPEN, TOOL_CALL_CLOSE } from "../../tools/toolcall-tags.ts";
21
+ import { robustParseJSON } from "../../utils/json.ts";
21
22
 
22
23
  export interface ParsedRequest {
23
24
  body: OpenAIRequest;
@@ -224,15 +225,19 @@ async function buildPromptFromMessages(
224
225
  if (typeof args === "string") {
225
226
  try {
226
227
  parsedArgs = JSON.parse(args);
227
- } catch (parseErr) {
228
- // Malformed JSON: preserve raw string for model visibility
229
- logger.warn("[chat] Failed to parse tool_call arguments", {
230
- toolCallId: tc.id,
231
- toolName: tc.function?.name,
232
- error: parseErr instanceof Error ? parseErr.message : "Unknown",
233
- rawArgs: args.substring(0, 200),
234
- });
235
- parsedArgs = { _raw: args };
228
+ } catch {
229
+ try {
230
+ parsedArgs = robustParseJSON(args);
231
+ } catch (parseErr) {
232
+ // Malformed JSON: preserve raw string for model visibility
233
+ logger.warn("[chat] Failed to parse tool_call arguments", {
234
+ toolCallId: tc.id,
235
+ toolName: tc.function?.name,
236
+ error: parseErr instanceof Error ? parseErr.message : "Unknown",
237
+ rawArgs: args.substring(0, 200),
238
+ });
239
+ parsedArgs = { _raw: args };
240
+ }
236
241
  }
237
242
  } else if (args && typeof args === "object") {
238
243
  parsedArgs = args;
@@ -265,6 +265,7 @@ export function loadStorageState(accountId: string): string | undefined {
265
265
  export async function saveStorageState(
266
266
  context: BrowserContext,
267
267
  accountId: string,
268
+ timeoutMs = 5_000,
268
269
  ): Promise<void> {
269
270
  try {
270
271
  const stateFile = getStorageStatePath(accountId);
@@ -272,7 +273,11 @@ export async function saveStorageState(
272
273
  if (!fs.existsSync(dir)) {
273
274
  fs.mkdirSync(dir, { recursive: true });
274
275
  }
275
- await context.storageState({ path: stateFile });
276
+ await withTimeout(
277
+ context.storageState({ path: stateFile }),
278
+ timeoutMs,
279
+ `storageState timed out after ${timeoutMs}ms`,
280
+ );
276
281
  } catch (error) {
277
282
  console.warn(
278
283
  `[Playwright] Failed to save storage state for ${accountId}: ${getErrorMessage(error)}`,
@@ -280,9 +285,13 @@ export async function saveStorageState(
280
285
  }
281
286
  }
282
287
 
283
- async function hasValidAuthCookie(context: BrowserContext): Promise<boolean> {
288
+ async function hasValidAuthCookie(context: BrowserContext, timeoutMs = 3_000): Promise<boolean> {
284
289
  try {
285
- const cookies = await context.cookies();
290
+ const cookies = await withTimeout(
291
+ context.cookies(),
292
+ timeoutMs,
293
+ `cookies check timed out after ${timeoutMs}ms`,
294
+ );
286
295
  return cookies.some(
287
296
  (c) =>
288
297
  (c.name.toLowerCase().includes("token") || c.name.toLowerCase().includes("session")) &&
@@ -9,6 +9,7 @@ import {
9
9
  getOpenNames,
10
10
  getCloseNames,
11
11
  matchToolCloseAt,
12
+ stripTrailingStrayCloses,
12
13
  } from "./toolcall-tags.ts";
13
14
 
14
15
  export interface ToolCallDelta {
@@ -190,6 +191,45 @@ function closeTagContentIsParseable(buffer: string, endIdx: number): boolean {
190
191
  return tryParseJsonToolPayload(content);
191
192
  }
192
193
 
194
+ function balanceClosingBrackets(content: string): string {
195
+ let inString = false;
196
+ let escaped = false;
197
+ const stack: string[] = [];
198
+ for (let i = 0; i < content.length; i++) {
199
+ const ch = content[i];
200
+ if (inString) {
201
+ if (escaped) {
202
+ escaped = false;
203
+ continue;
204
+ }
205
+ if (ch === "\\") {
206
+ escaped = true;
207
+ continue;
208
+ }
209
+ if (ch === '"') inString = false;
210
+ continue;
211
+ }
212
+ if (ch === '"') {
213
+ inString = true;
214
+ continue;
215
+ }
216
+ if (ch === "{" || ch === "[") stack.push(ch);
217
+ else if (ch === "}" || ch === "]") {
218
+ const top = stack[stack.length - 1];
219
+ if ((top === "{" && ch === "}") || (top === "[" && ch === "]")) {
220
+ stack.pop();
221
+ }
222
+ }
223
+ }
224
+ if (inString) return content;
225
+ let out = content;
226
+ while (stack.length > 0) {
227
+ const open = stack.pop();
228
+ out += open === "{" ? "}" : "]";
229
+ }
230
+ return out;
231
+ }
232
+
193
233
  /**
194
234
  * Plain-JSON.parse based candidate checks, in increasing tolerance order:
195
235
  * raw payload -> narrow typo repairs -> doubled trailing brace/bracket ->
@@ -214,9 +254,13 @@ function tryParseJsonToolPayload(content: string): boolean {
214
254
 
215
255
  const candidates = [repaired, stripped];
216
256
  if (repaired !== content) candidates.push(strippedRepaired);
217
- candidates.push(`{\"${content}`, `{${content}`);
218
- if (repaired !== content) candidates.push(`{\"${repaired}`, `{${repaired}`);
257
+ candidates.push(`{"${content}`, `{${content}`);
258
+ if (repaired !== content) candidates.push(`{"${repaired}`, `{${repaired}`);
219
259
 
260
+ const balanced = balanceClosingBrackets(content);
261
+ if (balanced !== content) candidates.push(balanced);
262
+ const balancedRepaired = balanceClosingBrackets(repaired);
263
+ if (balancedRepaired !== content) candidates.push(balancedRepaired);
220
264
  return candidates.some((candidate) => tryParse(candidate));
221
265
  }
222
266
 
@@ -999,6 +1043,10 @@ function isJsonPayloadTruncated(content: string): boolean {
999
1043
  if (content.includes('\\"')) {
1000
1044
  alt.push(content.replace(/\\"/g, '"'));
1001
1045
  }
1046
+ const balanced = balanceClosingBrackets(trimmed);
1047
+ if (balanced !== trimmed) {
1048
+ alt.push(balanced);
1049
+ }
1002
1050
  for (const candidate of alt) {
1003
1051
  if (!scanJsonStructureIncomplete(candidate)) return false;
1004
1052
  }
@@ -1864,7 +1912,7 @@ export class StreamingToolParser {
1864
1912
  // argument values (e.g. `{"a": "1</tool_call>"}`). Genuine unclosed
1865
1913
  // streams (cut mid-payload) have no trailing tag, so this is a no-op
1866
1914
  // for them.
1867
- const trimmed = rawTrimmed.replace(/<\/tool_calls?>$/i, "");
1915
+ const trimmed = stripTrailingStrayCloses(rawTrimmed).trim();
1868
1916
  if (trimmed.length > 0) {
1869
1917
  if (isToolcallDebugEnabled()) {
1870
1918
  logger.debug(
@@ -2570,10 +2618,13 @@ export class StreamingToolParser {
2570
2618
  // malformed tracking fires and the model re-emits cleanly.
2571
2619
  if (isJsonPayloadTruncated(block)) return null;
2572
2620
  const variants = [block];
2621
+ const balanced = balanceClosingBrackets(block);
2622
+ if (balanced !== block) {
2623
+ variants.push(balanced);
2624
+ }
2573
2625
  if (block.includes('\\"')) {
2574
2626
  variants.push(block.replace(/\\"/g, '"'));
2575
2627
  }
2576
-
2577
2628
  for (const variant of variants) {
2578
2629
  try {
2579
2630
  const parsed = robustParseJSON(variant);
package/src/utils/json.ts CHANGED
@@ -191,6 +191,13 @@ export function robustParseJSON(str: string): any {
191
191
  jsonPart = jsonPart.replace(/\\\\"/g, '\\"');
192
192
  }
193
193
 
194
+ // Heal stray backslashes escaping quotes on property keys or values
195
+ // e.g. "key\": \"val\" or "key\": "val" -> "key": "val"
196
+ jsonPart = jsonPart
197
+ .replace(/([{,]\s*)"([a-zA-Z0-9_-]+)\\"(\s*:)/g, '$1"$2"$3')
198
+ .replace(/:\s*\\"([^"\\]*)\\"(\s*[,}\]])/g, ': "$1"$2')
199
+ .replace(/:\s*\\"([^"\\]*)("(?:\s*[,}\]]))/g, ': "$1$2')
200
+ .replace(/:\s*"([^"\\]*)\\"(\s*[,}\]])/g, ': "$1"$2');
194
201
  let currentJson = jsonPart.replace(
195
202
  /([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g,
196
203
  '$1"$2"$3',