qwenproxy-cli 1.3.0 → 1.3.1

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.3.0",
3
+ "version": "1.3.1",
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": {
@@ -34,6 +34,14 @@ export function parseQwenErrorPayload(
34
34
 
35
35
  try {
36
36
  const payload = JSON.parse(text);
37
+ if (payload && (payload.update_member || payload.data?.update_member)) {
38
+ return {
39
+ code: "MembershipLimit",
40
+ details: "Account membership limit reached",
41
+ message: "Qwen upstream error: MembershipLimit: Account membership limit reached",
42
+ status: 429,
43
+ };
44
+ }
37
45
  if (payload && payload.success === false) {
38
46
  const code = payload.data?.code || payload.code || "UpstreamError";
39
47
  const details =
@@ -242,6 +242,8 @@ export function isQuotaLikeError(err: unknown): boolean {
242
242
  return (
243
243
  code === "quota_limit" ||
244
244
  code === "ratelimited" ||
245
+ code === "membership_limit" ||
246
+ code === "membershiplimit" ||
245
247
  message.includes("quota_limit") ||
246
248
  message.includes("quota exceeded") ||
247
249
  message.includes("allocated quota") ||
@@ -253,11 +255,16 @@ export function isQuotaLikeError(err: unknown): boolean {
253
255
  message.includes("rate increased too quickly") ||
254
256
  message.includes("upper limit for today's usage") ||
255
257
  message.includes("you've reached the upper limit") ||
258
+ message.includes("membership_limit") ||
259
+ message.includes("membership limit") ||
260
+ message.includes("update_member") ||
261
+ message.includes("update member") ||
256
262
  // Accept local rate_limit code only when message also looks like quota/rate
257
263
  (code === "rate_limit_exceeded" &&
258
264
  (message.includes("quota") ||
259
265
  message.includes("rate") ||
260
266
  message.includes("limit") ||
267
+ message.includes("membership") ||
261
268
  message.includes("demanda") ||
262
269
  message.includes("demand")))
263
270
  );
@@ -18,7 +18,7 @@ import {
18
18
  setToolCapNotice,
19
19
  } from "../../services/qwen.ts";
20
20
  import { acquireUpstreamStream } from "./account.ts";
21
- import { markAccountRateLimited } from "../../core/account-manager.ts";
21
+ import { markAccountRateLimited, computeQuotaCooldownMs } from "../../core/account-manager.ts";
22
22
  import {
23
23
  clearTemporaryBusy,
24
24
  markAccountTemporarilyBusy,
@@ -438,6 +438,20 @@ export async function processNonStreamingResponse(
438
438
  ) {
439
439
  const delta = chunk.choices[0].delta;
440
440
 
441
+ if (delta.extra?.update_member || chunk.update_member) {
442
+ throw toRetryableStreamError(
443
+ "membership_limit",
444
+ "Qwen upstream membership limit reached (update_member); rotating account",
445
+ {
446
+ switchAccount: true,
447
+ forceNewChat: true,
448
+ reason: "membership_limit",
449
+ accountCooldownMs: computeQuotaCooldownMs(Date.now()),
450
+ accountCooldownReason: "MembershipLimit",
451
+ },
452
+ );
453
+ }
454
+
441
455
  if (isThinkingPhase(delta.phase)) {
442
456
  isThinkingChunk = true;
443
457
  const formattedSummary = extractThinkingContent(delta);
@@ -952,6 +966,11 @@ export async function processStreamingResponse(
952
966
 
953
967
  // Release the lease immediately. The stop request below is best-effort
954
968
  // and must never hold the account slot or block the next tool turn.
969
+ if (onStreamComplete) {
970
+ try {
971
+ onStreamComplete();
972
+ } catch {}
973
+ }
955
974
  retryContext.releaseAccountLease?.();
956
975
  retryContext.releaseAccountLease = null;
957
976
  removeStream(completionId);
@@ -1739,6 +1758,20 @@ export async function processStreamingResponse(
1739
1758
  ) {
1740
1759
  const delta = chunk.choices[0].delta;
1741
1760
 
1761
+ if (delta.extra?.update_member || chunk.update_member) {
1762
+ throw toRetryableStreamError(
1763
+ "membership_limit",
1764
+ "Qwen upstream membership limit reached (update_member); rotating account",
1765
+ {
1766
+ switchAccount: true,
1767
+ forceNewChat: true,
1768
+ reason: "membership_limit",
1769
+ accountCooldownMs: computeQuotaCooldownMs(Date.now()),
1770
+ accountCooldownReason: "MembershipLimit",
1771
+ },
1772
+ );
1773
+ }
1774
+
1742
1775
  // Qwen streams may end with a {"status":"finished",
1743
1776
  // "phase":"answer"} delta and NO trailing [DONE]. Treat it as
1744
1777
  // the terminal event so we don't wait on the keep-alive
@@ -2624,11 +2657,17 @@ export async function processStreamingResponse(
2624
2657
  }
2625
2658
 
2626
2659
  // Release locks now that the stream is fully done
2627
- if (onStreamComplete) onStreamComplete();
2660
+ if (onStreamComplete) {
2661
+ try {
2662
+ onStreamComplete();
2663
+ } catch {}
2664
+ }
2628
2665
 
2629
2666
  // Release account lease from transparent retry if active
2630
2667
  if (retryContext.releaseAccountLease) {
2631
- retryContext.releaseAccountLease();
2668
+ try {
2669
+ retryContext.releaseAccountLease();
2670
+ } catch {}
2632
2671
  retryContext.releaseAccountLease = null;
2633
2672
  }
2634
2673
  }
@@ -379,14 +379,8 @@ export async function isPageLoggedIn(
379
379
  );
380
380
  if (!hasIdentity) return false;
381
381
 
382
- // DOM check: if login button is prominently visible, not logged in
383
- const loginBtn = document.querySelector(
384
- ".header-right-auth-button, button.header-right-auth-button, a[href*='/auth'], a[href*='/login']",
385
- );
386
- if (loginBtn && (loginBtn as HTMLElement).offsetParent !== null) {
387
- return false;
388
- }
389
-
382
+ // If an authenticated user object is confirmed with a real user identity,
383
+ // the session is 100% valid and verified by upstream.
390
384
  return true;
391
385
  } catch {
392
386
  return false;
@@ -1502,13 +1496,14 @@ export async function initPlaywrightForAccount(
1502
1496
  await sleep(1500);
1503
1497
  const currentUrl = acctPage.url();
1504
1498
  const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
1505
- if (isAuthUrl) {
1499
+ const loggedIn = !isAuthUrl && (await isPageLoggedIn(acctPage, 4_000));
1500
+ if (!loggedIn) {
1506
1501
  if (account.email && account.password) {
1507
1502
  console.log(
1508
- `[Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1503
+ `[Playwright] Session expired or guest state detected for ${maskEmail(account.email)}, re-authenticating...`,
1509
1504
  );
1510
1505
  const ok = await loginToQwen(account.id, account.email, account.password);
1511
- if (!ok || acctPage.url().includes("/auth") || acctPage.url().includes("/login")) {
1506
+ if (!ok || !(await isPageLoggedIn(acctPage, 4_000))) {
1512
1507
  validationError = new Error(
1513
1508
  `Session expired for ${maskEmail(account.email)} and re-authentication failed`,
1514
1509
  );
@@ -2038,10 +2033,29 @@ async function loginViaUi(
2038
2033
  return { success: false, reason: "Campo de e-mail não encontrado (possível captcha)" };
2039
2034
  }
2040
2035
 
2036
+ // In Qwen Web, if the page opens on the default email OTP panel, click "Log in with a password"
2037
+ // to reveal the standard email + password form.
2038
+ try {
2039
+ const pwdModeBtn = page.getByText(/Log in with a password/i).first();
2040
+ if (await pwdModeBtn.isVisible({ timeout: 1500 }).catch(() => false)) {
2041
+ await pwdModeBtn.click().catch(() => {});
2042
+ await sleep(500);
2043
+ }
2044
+ } catch {}
2045
+
2041
2046
  // Fill email
2042
2047
  await page.fill(emailSelector, email);
2043
2048
  await sleep(300);
2044
2049
 
2050
+ // If "Log in with a password" button appeared after typing email, click it
2051
+ try {
2052
+ const pwdModeBtn = page.getByText(/Log in with a password/i).first();
2053
+ if (await pwdModeBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
2054
+ await pwdModeBtn.click().catch(() => {});
2055
+ await sleep(500);
2056
+ }
2057
+ } catch {}
2058
+
2045
2059
  // In Qwen Web, the password field is present on the same form.
2046
2060
  // NEVER press Enter after filling email alone, as Qwen interprets that
2047
2061
  // as a request to sign in via email verification code (passwordless OTP).
@@ -2071,7 +2085,7 @@ async function loginViaUi(
2071
2085
  // Prefer clicking the submit button; fall back to pressing Enter.
2072
2086
  // The button starts disabled and only enables once both fields are filled.
2073
2087
  const submitSelector =
2074
- 'button[type="submit"].qwenchat-auth-pc-submit-button, button[type="submit"], button:has-text("Sign in")';
2088
+ 'button.qwenchat-auth-pc-submit-button, button[type="submit"], button:has-text("Log in"), button:has-text("Sign in")';
2075
2089
  const submitButton = page.locator(submitSelector).first();
2076
2090
  try {
2077
2091
  await page.waitForSelector('button[type="submit"]:not([disabled])', {
@@ -2388,18 +2402,19 @@ export async function captureQwenHeaders(
2388
2402
  if (settled || page.isClosed()) return;
2389
2403
 
2390
2404
  // Session-expiry fast path: if the page landed on the auth/login screen
2391
- // (redirection after a dead session), typing into the chat input would
2392
- // burn every trigger attempt on a textarea that does not exist. Re-login
2405
+ // or in a logged-out guest state, typing into the chat input would
2406
+ // burn every trigger attempt on a textarea that does not submit. Re-login
2393
2407
  // immediately when credentials are available; otherwise fail fast with a
2394
2408
  // clear diagnosis instead of 3 pointless grace timeouts.
2395
2409
  const currentUrl = page.url();
2396
2410
  const isAuthUrl = currentUrl.includes("/auth") || currentUrl.includes("/login");
2397
- if (isAuthUrl) {
2411
+ const loggedIn = !isAuthUrl && (await isPageLoggedIn(page, 3000));
2412
+ if (!loggedIn) {
2398
2413
  const { getAccountCredentials } = await import("../core/accounts.ts");
2399
2414
  const creds = getAccountCredentials(accountId);
2400
2415
  if (creds && creds.email && creds.password) {
2401
2416
  console.warn(
2402
- `⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
2417
+ `⚠️ [Playwright] Session expired or guest state detected during header capture for ${accountId}; re-authenticating...`,
2403
2418
  );
2404
2419
  const ok = await loginToQwen(accountId, creds.email, creds.password);
2405
2420
  if (ok) {
@@ -2407,7 +2422,7 @@ export async function captureQwenHeaders(
2407
2422
  // the check below probes a live authenticated chat page.
2408
2423
  await openChatPage();
2409
2424
  }
2410
- if (!ok || page.url().includes("/auth") || page.url().includes("/login")) {
2425
+ if (!ok || !(await isPageLoggedIn(page, 4000))) {
2411
2426
  settle(
2412
2427
  new Error(
2413
2428
  `Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
@@ -2470,12 +2485,26 @@ export async function captureQwenHeaders(
2470
2485
  try {
2471
2486
  await page.focus(inputSelector, { timeout: inputActionTimeoutMs });
2472
2487
  if (settled || page.isClosed()) return;
2473
- await page.fill(inputSelector, "", { timeout: inputActionTimeoutMs });
2474
- if (settled || page.isClosed()) return;
2475
- await page.type(inputSelector, "a", {
2476
- delay: 100,
2477
- timeout: inputActionTimeoutMs,
2478
- });
2488
+ await page.fill(inputSelector, "a", { timeout: inputActionTimeoutMs });
2489
+ if (typeof page.evaluate === "function") {
2490
+ await page.evaluate((sel) => {
2491
+ const el = document.querySelector(sel) as HTMLTextAreaElement | null;
2492
+ if (el) {
2493
+ el.focus();
2494
+ const nativeSetter = Object.getOwnPropertyDescriptor(
2495
+ window.HTMLTextAreaElement.prototype,
2496
+ "value",
2497
+ )?.set;
2498
+ if (nativeSetter) {
2499
+ nativeSetter.call(el, "a");
2500
+ } else {
2501
+ el.value = "a";
2502
+ }
2503
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2504
+ el.dispatchEvent(new Event("change", { bubbles: true }));
2505
+ }
2506
+ }, inputSelector).catch(() => {});
2507
+ }
2479
2508
  interactionSucceeded = true;
2480
2509
  break;
2481
2510
  } catch {
@@ -2505,6 +2534,9 @@ export async function captureQwenHeaders(
2505
2534
  ".message-input-right-button-send .send-button",
2506
2535
  ".chat-prompt-send-button",
2507
2536
  "button.send-button",
2537
+ "button[aria-label*='Send' i]",
2538
+ "button[aria-label*='Enviar' i]",
2539
+ ".send-button-container button",
2508
2540
  ];
2509
2541
 
2510
2542
  let clicked = false;
@@ -2513,18 +2545,28 @@ export async function captureQwenHeaders(
2513
2545
  try {
2514
2546
  const btn = await page.$(selector);
2515
2547
  if (btn && (await btn.isVisible())) {
2516
- await page.evaluate((sel) => {
2517
- const element = document.querySelector(sel) as HTMLElement;
2518
- if (element) {
2519
- element.focus();
2520
- element.click();
2548
+ const isDisabled = await page.evaluate((el) => {
2549
+ const b = el as HTMLButtonElement;
2550
+ return (
2551
+ b.disabled ||
2552
+ b.classList.contains("disabled") ||
2553
+ b.getAttribute("aria-disabled") === "true"
2554
+ );
2555
+ }, btn);
2556
+ if (!isDisabled) {
2557
+ await page.evaluate((sel) => {
2558
+ const element = document.querySelector(sel) as HTMLElement;
2559
+ if (element) {
2560
+ element.focus();
2561
+ element.click();
2562
+ }
2563
+ }, selector);
2564
+ if (!settled && !page.isClosed()) {
2565
+ await btn.click({ force: true, delay: 50 }).catch(() => {});
2521
2566
  }
2522
- }, selector);
2523
- if (!settled && !page.isClosed()) {
2524
- await btn.click({ force: true, delay: 50 }).catch(() => {});
2567
+ clicked = true;
2568
+ break;
2525
2569
  }
2526
- clicked = true;
2527
- break;
2528
2570
  }
2529
2571
  } catch {
2530
2572
  // Try the next selector.
@@ -629,6 +629,9 @@ function coerceParameterValue(rawValue: string): unknown {
629
629
  */
630
630
  function extractToolName(openTag: string, block: string): string {
631
631
  const combined = `${openTag}\n${block}`;
632
+ const fnMatch = combined.match(/<function=([^\s>]+)>/i);
633
+ if (fnMatch) return fnMatch[1].trim();
634
+
632
635
  const attrMatch = combined.match(
633
636
  /<tool_call(?:s)?\b[^>]*\bname\s*=\s*["']([^"']+)["']/i,
634
637
  );
@@ -640,6 +643,48 @@ function extractToolName(openTag: string, block: string): string {
640
643
  return "";
641
644
  }
642
645
 
646
+ /**
647
+ * Parse function shorthand XML <function=name><parameter=key>value</parameter></function> format.
648
+ */
649
+ function parseFunctionShorthandToolCall(
650
+ block: string,
651
+ openTag: string,
652
+ tools: ToolDefinitionLike[],
653
+ ): { name: string; arguments: Record<string, unknown> } | null {
654
+ const combined = `${openTag}\n${block}`;
655
+ const fnMatch = combined.match(/<function=([^\s>]+)>/i);
656
+ let toolName = fnMatch ? fnMatch[1].trim() : "";
657
+
658
+ const args: Record<string, unknown> = {};
659
+ const parameterRe = /<parameter=([^\s>]+)>([\s\S]*?)<\/parameter>/gi;
660
+ let match: RegExpExecArray | null = parameterRe.exec(block);
661
+ while (match !== null) {
662
+ args[match[1]] = coerceParameterValue(match[2]);
663
+ match = parameterRe.exec(block);
664
+ }
665
+
666
+ // Also support <parameter name="..."> inside <function=...>
667
+ if (Object.keys(args).length === 0) {
668
+ const namedParamRe =
669
+ /<parameter\b[^>]*\bname\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/parameter>/gi;
670
+ let namedMatch: RegExpExecArray | null = namedParamRe.exec(block);
671
+ while (namedMatch !== null) {
672
+ args[namedMatch[1]] = coerceParameterValue(namedMatch[2]);
673
+ namedMatch = namedParamRe.exec(block);
674
+ }
675
+ }
676
+
677
+ if (Object.keys(args).length === 0) return null;
678
+
679
+ if (!toolName) {
680
+ toolName =
681
+ extractToolName(openTag, block) || inferToolNameFromParameters(args, tools);
682
+ }
683
+ if (!toolName) return null;
684
+
685
+ return { name: toolName, arguments: args };
686
+ }
687
+
643
688
  /**
644
689
  * Infer tool name by matching parameter keys against tool definitions.
645
690
  * Only returns a name if exactly one tool matches all argument keys.
@@ -2193,6 +2238,47 @@ export class StreamingToolParser {
2193
2238
  });
2194
2239
  }
2195
2240
 
2241
+ // 0) Try function shorthand format (<function=name><parameter=k>v</parameter></function>)
2242
+ const fnShorthand = parseFunctionShorthandToolCall(
2243
+ t,
2244
+ this.currentOpenTag,
2245
+ this.tools,
2246
+ );
2247
+ if (fnShorthand) {
2248
+ const resolvedName = this.resolveDeclaredToolName(fnShorthand.name);
2249
+ if (!resolvedName) {
2250
+ this.recordMalformedToolCall(content, {
2251
+ undeclaredNames: [fnShorthand.name],
2252
+ category: "undeclared",
2253
+ });
2254
+ this.preserveLiteralToolCall(
2255
+ content,
2256
+ result,
2257
+ `undeclared tool name: ${fnShorthand.name}`,
2258
+ );
2259
+ return;
2260
+ }
2261
+ fnShorthand.name = resolvedName;
2262
+ if (isToolcallDebugEnabled()) {
2263
+ logger.debug(
2264
+ "[parser] processToolContent: function shorthand parsed successfully",
2265
+ {
2266
+ name: fnShorthand.name,
2267
+ arguments: fnShorthand.arguments,
2268
+ },
2269
+ );
2270
+ }
2271
+ this.finalizeSuccessfulToolCall(
2272
+ {
2273
+ id: `call_${crypto.randomUUID()}`,
2274
+ name: fnShorthand.name,
2275
+ arguments: fnShorthand.arguments,
2276
+ },
2277
+ result,
2278
+ );
2279
+ return;
2280
+ }
2281
+
2196
2282
  // 1) Try Hermes-style XML <parameter> format first
2197
2283
  const xmlParsed = parseXmlParameterToolCall(
2198
2284
  t,
@@ -2488,6 +2574,23 @@ export class StreamingToolParser {
2488
2574
  });
2489
2575
  }
2490
2576
 
2577
+ // Try function shorthand format first
2578
+ const fnShorthand = parseFunctionShorthandToolCall(
2579
+ block,
2580
+ this.currentOpenTag,
2581
+ this.tools,
2582
+ );
2583
+ if (fnShorthand) {
2584
+ const resolvedName = this.resolveDeclaredToolName(fnShorthand.name);
2585
+ if (resolvedName) {
2586
+ return {
2587
+ id: `call_${crypto.randomUUID()}`,
2588
+ name: resolvedName,
2589
+ arguments: fnShorthand.arguments,
2590
+ };
2591
+ }
2592
+ }
2593
+
2491
2594
  // Try full parse first
2492
2595
  const xmlParsed = parseXmlParameterToolCall(
2493
2596
  block,
@@ -32,12 +32,12 @@ function tagName(tag: string): string {
32
32
 
33
33
  /** All accepted open tag names (custom + legacy). */
34
34
  export function getOpenNames(): string[] {
35
- return Array.from(new Set([tagName(TOOL_CALL_OPEN), "tool_call", "tool_calls"]));
35
+ return Array.from(new Set([tagName(TOOL_CALL_OPEN), "tool_call", "tool_calls", "function"]));
36
36
  }
37
37
 
38
38
  /** All accepted close tag names (custom + legacy). */
39
39
  export function getCloseNames(): string[] {
40
- return Array.from(new Set([tagName(TOOL_CALL_CLOSE), "tool_call", "tool_calls", "tool"]));
40
+ return Array.from(new Set([tagName(TOOL_CALL_CLOSE), "tool_call", "tool_calls", "tool", "function"]));
41
41
  }
42
42
 
43
43
  /** Wrap a JSON payload in the canonical open/close tags. */