qwenproxy-cli 1.0.16 → 1.0.18

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.16",
3
+ "version": "1.0.18",
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) => {
package/src/index.ts CHANGED
@@ -14,41 +14,35 @@ if (fs.existsSync(envPath)) {
14
14
  dotenv.config({ quiet: true })
15
15
  }
16
16
  // Prevent benign asynchronous driver/browser teardown exceptions from crashing the server
17
- process.on('uncaughtException', (error: unknown) => {
18
- const msg = error instanceof Error ? error.message : String(error)
19
- if (
20
- msg.includes('Cannot find parent object') ||
21
- msg.includes('Target page, context or browser has been closed') ||
22
- msg.includes('Browser has been closed') ||
23
- msg.includes('Target closed') ||
24
- msg.includes('Target crashed') ||
25
- msg.includes('Page crashed') ||
26
- msg.includes('Assertion error') ||
27
- msg.includes('Connection closed')
28
- ) {
29
- console.warn(`⚠️ [Playwright] Handled benign driver teardown exception: ${msg}`)
30
- return
17
+ process.on('uncaughtException', async (error: unknown) => {
18
+ const { isPlaywrightAlreadyClosedError } = await import('./services/playwright.ts');
19
+ if (isPlaywrightAlreadyClosedError(error)) {
20
+ const msg =
21
+ error instanceof Error
22
+ ? error.message
23
+ : typeof error === 'object' && error !== null && 'message' in error
24
+ ? String((error as any).message)
25
+ : String(error);
26
+ console.warn(`⚠️ [Playwright] Handled benign driver teardown exception: ${msg}`);
27
+ return;
31
28
  }
32
- console.error('❌ [Process] Uncaught Exception:', error)
33
- })
29
+ console.error('❌ [Process] Uncaught Exception:', error);
30
+ });
34
31
 
35
- process.on('unhandledRejection', (reason: unknown) => {
36
- const msg = reason instanceof Error ? reason.message : String(reason)
37
- if (
38
- msg.includes('Cannot find parent object') ||
39
- msg.includes('Target page, context or browser has been closed') ||
40
- msg.includes('Browser has been closed') ||
41
- msg.includes('Target closed') ||
42
- msg.includes('Target crashed') ||
43
- msg.includes('Page crashed') ||
44
- msg.includes('Assertion error') ||
45
- msg.includes('Connection closed')
46
- ) {
47
- console.warn(`⚠️ [Playwright] Handled benign driver teardown rejection: ${msg}`)
48
- return
32
+ process.on('unhandledRejection', async (reason: unknown) => {
33
+ const { isPlaywrightAlreadyClosedError } = await import('./services/playwright.ts');
34
+ if (isPlaywrightAlreadyClosedError(reason)) {
35
+ const msg =
36
+ reason instanceof Error
37
+ ? reason.message
38
+ : typeof reason === 'object' && reason !== null && 'message' in reason
39
+ ? String((reason as any).message)
40
+ : String(reason);
41
+ console.warn(`⚠️ [Playwright] Handled benign driver teardown rejection: ${msg}`);
42
+ return;
49
43
  }
50
- console.error('❌ [Process] Unhandled Rejection:', reason)
51
- })
44
+ console.error('❌ [Process] Unhandled Rejection:', reason);
45
+ });
52
46
  import { startServer } from './api/server.js'
53
47
  const isTui = process.argv.includes('--tui') || process.env.QWEN_TUI === 'true'
54
48
 
@@ -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;
@@ -492,7 +492,7 @@ function isAccountServingStream(accountId: string): boolean {
492
492
  return true;
493
493
  }
494
494
 
495
- function getStealthScript(profile: FingerprintProfile): string {
495
+ export function getStealthScript(profile: FingerprintProfile): string {
496
496
  const profileJson = JSON.stringify(profile).replace(/</g, "\\u003c");
497
497
  return `
498
498
  (function() {
@@ -740,6 +740,14 @@ function getStealthScript(profile: FingerprintProfile): string {
740
740
  function makeMime(desc, suffixes, type) {
741
741
  return { description: desc, suffixes: suffixes, type: type };
742
742
  }
743
+ function attachPlugin(mime, plugin) {
744
+ Object.defineProperty(mime, 'enabledPlugin', {
745
+ value: plugin,
746
+ enumerable: false,
747
+ configurable: true,
748
+ writable: true,
749
+ });
750
+ }
743
751
  const pdfMime = makeMime('Portable Document Format', 'pdf', 'application/pdf');
744
752
  const pdfxMime = makeMime('Portable Document Format', 'pdf', 'text/pdf');
745
753
  const pdfPlugin = {
@@ -750,8 +758,8 @@ function getStealthScript(profile: FingerprintProfile): string {
750
758
  0: pdfMime,
751
759
  1: pdfxMime,
752
760
  };
753
- pdfMime.enabledPlugin = pdfPlugin;
754
- pdfxMime.enabledPlugin = pdfPlugin;
761
+ attachPlugin(pdfMime, pdfPlugin);
762
+ attachPlugin(pdfxMime, pdfPlugin);
755
763
 
756
764
  const chromePdfMime = makeMime('Portable Document Format', 'pdf', 'application/pdf');
757
765
  const chromePdfMime2 = makeMime('Portable Document Format', 'pdf', 'text/pdf');
@@ -763,8 +771,8 @@ function getStealthScript(profile: FingerprintProfile): string {
763
771
  0: chromePdfMime,
764
772
  1: chromePdfMime2,
765
773
  };
766
- chromePdfMime.enabledPlugin = chromePdfPlugin;
767
- chromePdfMime2.enabledPlugin = chromePdfPlugin;
774
+ attachPlugin(chromePdfMime, chromePdfPlugin);
775
+ attachPlugin(chromePdfMime2, chromePdfPlugin);
768
776
 
769
777
  const nativePlugin = {
770
778
  name: 'Native Client',
@@ -774,9 +782,8 @@ function getStealthScript(profile: FingerprintProfile): string {
774
782
  0: makeMime('Native Client Executable', '', 'application/x-nacl'),
775
783
  1: makeMime('Portable Native Client Executable', '', 'application/x-pnacl'),
776
784
  };
777
- nativePlugin[0].enabledPlugin = nativePlugin;
778
- nativePlugin[1].enabledPlugin = nativePlugin;
779
-
785
+ attachPlugin(nativePlugin[0], nativePlugin);
786
+ attachPlugin(nativePlugin[1], nativePlugin);
780
787
  const pluginsList = [pdfPlugin, chromePdfPlugin, nativePlugin];
781
788
  const mimeList = [pdfMime, pdfxMime, chromePdfMime, chromePdfMime2, nativePlugin[0], nativePlugin[1]];
782
789
 
@@ -3078,7 +3085,13 @@ async function closePlaywrightForAccountLocked(
3078
3085
  * that must not be logged as keep-alive failures.
3079
3086
  */
3080
3087
  export function isPlaywrightAlreadyClosedError(error: unknown): boolean {
3081
- const message = error instanceof Error ? error.message : String(error);
3088
+ if (!error) return false;
3089
+ const message =
3090
+ error instanceof Error
3091
+ ? error.message
3092
+ : typeof error === "object" && "message" in error
3093
+ ? String((error as any).message)
3094
+ : String(error);
3082
3095
  return (
3083
3096
  message.includes("Target page, context or browser has been closed") ||
3084
3097
  message.includes("Browser has been closed") ||
@@ -3087,7 +3100,11 @@ export function isPlaywrightAlreadyClosedError(error: unknown): boolean {
3087
3100
  message.includes("Page crashed") ||
3088
3101
  message.includes("Assertion error") ||
3089
3102
  message.includes("Cannot find parent object") ||
3090
- message.includes("Connection closed")
3103
+ message.includes("Connection closed") ||
3104
+ message.includes("session closed") ||
3105
+ message.includes("Session closed") ||
3106
+ message.includes("Network.setCacheDisabled") ||
3107
+ message.includes("Protocol error")
3091
3108
  );
3092
3109
  }
3093
3110
 
@@ -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
 
@@ -918,7 +962,7 @@ function repairCommonMalformedToolJson(content: string): string {
918
962
  // a bare-word value, so inserting the quote is safe (true/false/null and
919
963
  // numbers are excluded). The trailing `\"` escapes are preserved, so the
920
964
  // model's closing quote still terminates the string.
921
- /([,{]\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*)(?=(?!true|false|null)[A-Za-z_])/g,
965
+ /([,{]\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*)(?=(?!true|false|null|\d|\[|\{|")[^\s])/g,
922
966
  '$1"',
923
967
  );
924
968
  return repairMissingArrayClose(repaired);
@@ -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/tui/index.ts CHANGED
@@ -37,6 +37,10 @@ async function main() {
37
37
  const app = new TuiApp(initialTab);
38
38
 
39
39
  process.on("uncaughtException", async (err) => {
40
+ const { isPlaywrightAlreadyClosedError } = await import("../services/playwright.ts");
41
+ if (isPlaywrightAlreadyClosedError(err)) {
42
+ return;
43
+ }
40
44
  try {
41
45
  await app.stop();
42
46
  } catch {}
@@ -45,6 +49,10 @@ async function main() {
45
49
  });
46
50
 
47
51
  process.on("unhandledRejection", async (err) => {
52
+ const { isPlaywrightAlreadyClosedError } = await import("../services/playwright.ts");
53
+ if (isPlaywrightAlreadyClosedError(err)) {
54
+ return;
55
+ }
48
56
  try {
49
57
  await app.stop();
50
58
  } catch {}
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',