qwenproxy-cli 1.3.0 → 1.3.2
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 +1 -1
- package/src/routes/chat/errors.ts +8 -0
- package/src/routes/chat/retry-policy.ts +7 -0
- package/src/routes/chat/streaming.ts +42 -3
- package/src/services/playwright.ts +147 -105
- package/src/services/qwen.ts +2 -2
- package/src/tools/parser.ts +103 -0
- package/src/tools/toolcall-tags.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qwenproxy-cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
383
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
);
|
|
@@ -1894,94 +1889,94 @@ async function loginViaApi(
|
|
|
1894
1889
|
.digest("hex");
|
|
1895
1890
|
const signinUrl = qwenUrl("/api/v2/auths/signin");
|
|
1896
1891
|
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1892
|
+
let signinSuccess = false;
|
|
1893
|
+
let data: any = null;
|
|
1894
|
+
|
|
1895
|
+
if (page.request && typeof page.request.post === "function") {
|
|
1896
|
+
try {
|
|
1897
|
+
const response = await page.request.post(signinUrl, {
|
|
1898
|
+
data: {
|
|
1899
|
+
email,
|
|
1900
|
+
password: hashedPassword,
|
|
1901
|
+
login_type: "email",
|
|
1902
|
+
},
|
|
1903
|
+
headers: {
|
|
1904
|
+
"content-type": "application/json",
|
|
1905
|
+
accept: "application/json, text/plain, */*",
|
|
1906
|
+
referer: qwenUrl("/auth"),
|
|
1907
|
+
origin: qwenOrigin(),
|
|
1908
|
+
},
|
|
1909
|
+
timeout: 10_000,
|
|
1910
|
+
});
|
|
1911
|
+
data = await response.json().catch(() => null);
|
|
1912
|
+
signinSuccess = Boolean(data && (data.success === true || data.token));
|
|
1913
|
+
} catch {}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (!signinSuccess && !data) {
|
|
1917
|
+
// Fallback to in-page evaluate fetch if page.request was unavailable
|
|
1918
|
+
const requestId = crypto.randomUUID();
|
|
1919
|
+
const evalRes = await page
|
|
1920
|
+
.evaluate(
|
|
1921
|
+
async ({ email, password, signinUrl, requestId }) => {
|
|
1917
1922
|
try {
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1923
|
+
const response = await fetch(signinUrl, {
|
|
1924
|
+
method: "POST",
|
|
1925
|
+
signal: AbortSignal.timeout(10_000),
|
|
1926
|
+
credentials: "include",
|
|
1927
|
+
headers: {
|
|
1928
|
+
accept: "application/json, text/plain, */*",
|
|
1929
|
+
"content-type": "application/json",
|
|
1930
|
+
source: "web",
|
|
1931
|
+
timezone: new Date().toString().split(" (")[0],
|
|
1932
|
+
"x-request-id": requestId,
|
|
1933
|
+
},
|
|
1934
|
+
body: JSON.stringify({ email, password, login_type: "email" }),
|
|
1935
|
+
});
|
|
1936
|
+
const json = await response.json().catch(() => null);
|
|
1937
|
+
return { ok: response.ok, status: response.status, data: json };
|
|
1938
|
+
} catch (e: any) {
|
|
1939
|
+
return { ok: false, error: e.message };
|
|
1940
|
+
}
|
|
1941
|
+
},
|
|
1942
|
+
{ email, password: hashedPassword, signinUrl, requestId },
|
|
1943
|
+
)
|
|
1944
|
+
.catch(() => null);
|
|
1929
1945
|
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
.goto(qwenUrl("/"), {
|
|
1934
|
-
waitUntil: "domcontentloaded",
|
|
1935
|
-
timeout: config.timeouts.navigation,
|
|
1936
|
-
})
|
|
1937
|
-
.catch(() => {});
|
|
1938
|
-
await sleep(1000);
|
|
1939
|
-
const url = page.url();
|
|
1940
|
-
const isLogged = !(url.includes("/auth") || url.includes("/login"));
|
|
1941
|
-
if (isLogged) {
|
|
1942
|
-
return { success: true };
|
|
1943
|
-
}
|
|
1944
|
-
const loggedIn = await isPageLoggedIn(page);
|
|
1945
|
-
if (loggedIn) {
|
|
1946
|
-
return { success: true };
|
|
1947
|
-
}
|
|
1948
|
-
} else if (result.data.success === false) {
|
|
1949
|
-
const code = result.data?.data?.code || result.data?.code;
|
|
1950
|
-
const details =
|
|
1951
|
-
result.data?.data?.details ||
|
|
1952
|
-
result.data?.details ||
|
|
1953
|
-
result.data?.message;
|
|
1954
|
-
const classified = classifyQwenAuthError(code, details);
|
|
1955
|
-
return {
|
|
1956
|
-
success: false,
|
|
1957
|
-
permanentFailure: classified.isPermanent,
|
|
1958
|
-
reason: classified.reason,
|
|
1959
|
-
};
|
|
1946
|
+
if (evalRes?.data) {
|
|
1947
|
+
data = evalRes.data;
|
|
1948
|
+
signinSuccess = Boolean(data && (data.success === true || data.token));
|
|
1960
1949
|
}
|
|
1961
1950
|
}
|
|
1962
1951
|
|
|
1963
|
-
if (
|
|
1952
|
+
if (data?.success === false) {
|
|
1953
|
+
const code = data?.data?.code || data?.code;
|
|
1954
|
+
const details = data?.data?.details || data?.details || data?.message;
|
|
1955
|
+
const classified = classifyQwenAuthError(code, details);
|
|
1956
|
+
return {
|
|
1957
|
+
success: false,
|
|
1958
|
+
permanentFailure: classified.isPermanent,
|
|
1959
|
+
reason: classified.reason,
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
if (signinSuccess) {
|
|
1964
1964
|
await page
|
|
1965
1965
|
.goto(qwenUrl("/"), {
|
|
1966
1966
|
waitUntil: "domcontentloaded",
|
|
1967
1967
|
timeout: config.timeouts.navigation,
|
|
1968
1968
|
})
|
|
1969
1969
|
.catch(() => {});
|
|
1970
|
-
await sleep(
|
|
1971
|
-
const
|
|
1972
|
-
|
|
1973
|
-
if (isLogged) {
|
|
1970
|
+
await sleep(1500);
|
|
1971
|
+
const loggedIn = await isPageLoggedIn(page, 4_000);
|
|
1972
|
+
if (loggedIn) {
|
|
1974
1973
|
return { success: true };
|
|
1975
1974
|
}
|
|
1976
|
-
const loggedIn = await isPageLoggedIn(page);
|
|
1977
|
-
return {
|
|
1978
|
-
success: loggedIn,
|
|
1979
|
-
reason: loggedIn ? undefined : "Sessão não autenticada após signin",
|
|
1980
|
-
};
|
|
1981
1975
|
}
|
|
1976
|
+
|
|
1982
1977
|
return {
|
|
1983
1978
|
success: false,
|
|
1984
|
-
reason:
|
|
1979
|
+
reason: "API signin não confirmou sessão autenticada",
|
|
1985
1980
|
};
|
|
1986
1981
|
} catch (err: any) {
|
|
1987
1982
|
const errMsg = err?.message || String(err);
|
|
@@ -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
|
|
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
|
-
//
|
|
2392
|
-
// burn every trigger attempt on a textarea that does not
|
|
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
|
-
|
|
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 ||
|
|
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 (
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
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((
|
|
2517
|
-
const
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
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
|
-
|
|
2523
|
-
|
|
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.
|
package/src/services/qwen.ts
CHANGED
|
@@ -985,12 +985,12 @@ export async function requestQwenSettingsDirectFetch(
|
|
|
985
985
|
// passes the WAF.
|
|
986
986
|
let json: any = null;
|
|
987
987
|
let okShape = false;
|
|
988
|
-
if (contentType.includes("html")) {
|
|
988
|
+
if (contentType.includes("html") || !response.ok) {
|
|
989
989
|
okShape = false;
|
|
990
990
|
} else {
|
|
991
991
|
try {
|
|
992
992
|
json = JSON.parse(raw);
|
|
993
|
-
okShape = json && typeof json === "object" &&
|
|
993
|
+
okShape = json && typeof json === "object" && json.success === true;
|
|
994
994
|
} catch {
|
|
995
995
|
okShape = false;
|
|
996
996
|
}
|
package/src/tools/parser.ts
CHANGED
|
@@ -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. */
|