qwenproxy-cli 1.0.21 → 1.0.22
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/core/mutex.ts +10 -0
- package/src/routes/chat/account.ts +11 -4
- package/src/routes/chat/streaming.ts +4 -3
- package/src/services/captcha-coordinator.ts +11 -10
- package/src/services/playwright.ts +27 -8
- package/src/services/qwen.ts +13 -48
- package/src/tools/parser.ts +22 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qwenproxy-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.22",
|
|
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/core/mutex.ts
CHANGED
|
@@ -102,6 +102,16 @@ export class Mutex {
|
|
|
102
102
|
this.lockedByKey = "";
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Heartbeat the lock: extend lockedAt while the holder is actively making progress.
|
|
107
|
+
* Prevents premature force-release during long-running streaming generations.
|
|
108
|
+
*/
|
|
109
|
+
touch(key?: string): void {
|
|
110
|
+
if (this.locked && (!key || this.lockedByKey === key)) {
|
|
111
|
+
this.lockedAt = Date.now();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
/** Returns true if the mutex is not locked and has no waiting queue. */
|
|
106
116
|
isIdle(): boolean {
|
|
107
117
|
return !this.locked && this.queue.length === 0;
|
|
@@ -112,10 +112,11 @@ const personalizationLocks = new Map<string, Mutex>();
|
|
|
112
112
|
mutex = new Mutex(
|
|
113
113
|
`chat:${chatId.substring(0, 8)}`,
|
|
114
114
|
// The chat lock is held for the whole stream lifetime. A long
|
|
115
|
-
// generation (reasoning + huge context) can legitimately
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
115
|
+
// generation (reasoning + huge context) can legitimately take 4-5 min;
|
|
116
|
+
// use config.timeouts.totalRequestTimeout (600s) as the max hold limit, while
|
|
117
|
+
// touchChatLock extends it on every chunk so active streams are never
|
|
118
|
+
// force-released mid-generation.
|
|
119
|
+
config.timeouts.totalRequestTimeout,
|
|
119
120
|
);
|
|
120
121
|
chatLocks.set(chatId, mutex);
|
|
121
122
|
}
|
|
@@ -141,6 +142,12 @@ const personalizationLocks = new Map<string, Mutex>();
|
|
|
141
142
|
};
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
export function touchChatLock(chatId: string | undefined): void {
|
|
146
|
+
if (!chatId) return;
|
|
147
|
+
const mutex = chatLocks.get(chatId);
|
|
148
|
+
mutex?.touch();
|
|
149
|
+
}
|
|
150
|
+
|
|
144
151
|
async function acquirePersonalizationLock(
|
|
145
152
|
accountId: string,
|
|
146
153
|
): Promise<() => void> {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
shouldRetryInvalidInputOnSameAccount,
|
|
31
31
|
} from "./retry-policy.ts";
|
|
32
32
|
import type { Message, OpenAIRequest, Usage } from "../../utils/types.ts";
|
|
33
|
+
import { touchChatLock } from "./account.ts";
|
|
33
34
|
import { StreamingToolParser } from "../../tools/parser.ts";
|
|
34
35
|
import {
|
|
35
36
|
getStream,
|
|
@@ -351,7 +352,7 @@ export async function processNonStreamingResponse(
|
|
|
351
352
|
while (true) {
|
|
352
353
|
const { done, value } = await reader.read();
|
|
353
354
|
if (done) break;
|
|
354
|
-
|
|
355
|
+
touchChatLock(currentUiSessionId);
|
|
355
356
|
const decoded = decoder.decode(value, { stream: true });
|
|
356
357
|
if (!sawSseProtocol) {
|
|
357
358
|
protocolBuffer += decoded;
|
|
@@ -1598,7 +1599,7 @@ export async function processStreamingResponse(
|
|
|
1598
1599
|
throw readError;
|
|
1599
1600
|
}
|
|
1600
1601
|
if (readResult.done) break;
|
|
1601
|
-
|
|
1602
|
+
touchChatLock(currentUiSessionId);
|
|
1602
1603
|
buffer += decoder.decode(readResult.value, { stream: true });
|
|
1603
1604
|
lineEnd = buffer.indexOf("\n");
|
|
1604
1605
|
if (lineEnd === -1) continue;
|
|
@@ -2141,7 +2142,7 @@ export async function processStreamingResponse(
|
|
|
2141
2142
|
retryReadLoop: while (true) {
|
|
2142
2143
|
const { done, value } = await retryReader.read();
|
|
2143
2144
|
if (done) break;
|
|
2144
|
-
|
|
2145
|
+
touchChatLock(currentUiSessionId);
|
|
2145
2146
|
retryBuf += retryDecoder.decode(value, { stream: true });
|
|
2146
2147
|
let lineStart = 0;
|
|
2147
2148
|
let lineEnd = retryBuf.indexOf("\n", lineStart);
|
|
@@ -111,26 +111,27 @@ export async function recoverBaxiaCaptcha(
|
|
|
111
111
|
|
|
112
112
|
// The slider itself waits up to 5s for each attempt. Keep the page
|
|
113
113
|
// operation alive for the full bounded solver budget so a slow challenge
|
|
114
|
-
// cannot be mistaken for a stuck browser
|
|
114
|
+
// cannot be mistaken for a stuck browser. Do not clamp to timeouts.page.
|
|
115
115
|
// Two navigations (open the challenge, return to the chat page) are part of
|
|
116
116
|
// the recovery, so their budget belongs in the same total.
|
|
117
|
-
const solverOperationTimeoutMs = Math.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
(3_000 + config.captcha.retryDelayMs + config.captcha.settleMs) +
|
|
124
|
-
2 * CHALLENGE_NAVIGATION_TIMEOUT_MS,
|
|
125
|
-
),
|
|
117
|
+
const solverOperationTimeoutMs = Math.max(
|
|
118
|
+
15_000,
|
|
119
|
+
config.captcha.timeoutMs +
|
|
120
|
+
config.captcha.maxAttempts *
|
|
121
|
+
(3_000 + config.captcha.retryDelayMs + config.captcha.settleMs) +
|
|
122
|
+
2 * CHALLENGE_NAVIGATION_TIMEOUT_MS,
|
|
126
123
|
);
|
|
127
124
|
|
|
128
125
|
try {
|
|
126
|
+
// recoverOnTimeout: false ensures that if the captcha solve times out,
|
|
127
|
+
// withAccountPage does NOT destructively kill the browser context, which
|
|
128
|
+
// previously caused "Target page, context or browser has been closed" mid-slider.
|
|
129
129
|
const solved = await withAccountPage(
|
|
130
130
|
accountId,
|
|
131
131
|
(page) => solveChallengeOnPage(page, challengeUrl),
|
|
132
132
|
solverOperationTimeoutMs,
|
|
133
133
|
Math.min(config.timeouts.page, 5_000),
|
|
134
|
+
false,
|
|
134
135
|
);
|
|
135
136
|
|
|
136
137
|
metrics.histogram("captcha.solve.duration", Date.now() - startedAt, {
|
|
@@ -199,7 +199,11 @@ async function recoverStuckAccountMutex(
|
|
|
199
199
|
// A normal request can recover the browser on the next attempt. Avoid
|
|
200
200
|
// recursively scheduling another reset when the reset/close path itself was
|
|
201
201
|
// the operation that timed out.
|
|
202
|
-
if (
|
|
202
|
+
if (
|
|
203
|
+
!key.startsWith("profile-reset:") &&
|
|
204
|
+
!key.startsWith("close:") &&
|
|
205
|
+
!key.startsWith("init:")
|
|
206
|
+
) {
|
|
203
207
|
schedulePlaywrightProfileReset(accountId);
|
|
204
208
|
}
|
|
205
209
|
}
|
|
@@ -232,7 +236,7 @@ async function acquireAccountMutex(
|
|
|
232
236
|
const accountContexts = new Map<string, BrowserContext>();
|
|
233
237
|
const accountPages = new Map<string, Page>();
|
|
234
238
|
const cachedUserAgents = new Map<string, string>();
|
|
235
|
-
|
|
239
|
+
const inFlightAccountInits = new Map<string, Promise<void>>();
|
|
236
240
|
let sharedBrowser: Browser | null = null;
|
|
237
241
|
let sharedBrowserPromise: Promise<Browser> | null = null;
|
|
238
242
|
|
|
@@ -1294,12 +1298,19 @@ export async function initPlaywrightForAccount(
|
|
|
1294
1298
|
);
|
|
1295
1299
|
return;
|
|
1296
1300
|
}
|
|
1301
|
+
const existingInit = inFlightAccountInits.get(account.id);
|
|
1302
|
+
if (existingInit) {
|
|
1303
|
+
await existingInit;
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1297
1306
|
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1307
|
+
const initPromise = (async () => {
|
|
1308
|
+
const release = await acquireAccountMutex(
|
|
1309
|
+
account.id,
|
|
1310
|
+
`init:${account.id.substring(0, 12)}`,
|
|
1311
|
+
Math.max(PLAYWRIGHT_MUTEX_WAIT_MS, 120_000),
|
|
1312
|
+
);
|
|
1313
|
+
try {
|
|
1303
1314
|
// Double-check after acquiring lock
|
|
1304
1315
|
if (accountPages.has(account.id)) {
|
|
1305
1316
|
return;
|
|
@@ -1485,8 +1496,16 @@ export async function initPlaywrightForAccount(
|
|
|
1485
1496
|
cleanupPlaywrightAccountState(account.id);
|
|
1486
1497
|
throw error;
|
|
1487
1498
|
}
|
|
1499
|
+
} finally {
|
|
1500
|
+
release();
|
|
1501
|
+
}
|
|
1502
|
+
})();
|
|
1503
|
+
|
|
1504
|
+
inFlightAccountInits.set(account.id, initPromise);
|
|
1505
|
+
try {
|
|
1506
|
+
await initPromise;
|
|
1488
1507
|
} finally {
|
|
1489
|
-
|
|
1508
|
+
inFlightAccountInits.delete(account.id);
|
|
1490
1509
|
}
|
|
1491
1510
|
}
|
|
1492
1511
|
|
package/src/services/qwen.ts
CHANGED
|
@@ -733,14 +733,15 @@ async function withQwenBrowserPage<T>(
|
|
|
733
733
|
operationTimeoutMs = config.timeouts.page,
|
|
734
734
|
recoverOnTimeout = true,
|
|
735
735
|
): Promise<T> {
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
const targetUrl = qwenUrl(
|
|
736
|
+
// When targetPath is omitted, any page under the Qwen origin can run the
|
|
737
|
+
// in-page evaluate/fetch without an expensive, stream-breaking page.goto.
|
|
738
|
+
// Only navigate when targetPath is explicitly provided or when the page is not
|
|
739
|
+
// on the Qwen origin yet (e.g. about:blank).
|
|
740
|
+
const targetUrl = qwenUrl(targetPath || "/");
|
|
741
741
|
const targetOrigin = new URL(targetUrl).origin;
|
|
742
|
-
const normalizedTargetPath =
|
|
743
|
-
new URL(targetUrl).pathname.replace(/\/+$/, "") || "/"
|
|
742
|
+
const normalizedTargetPath = targetPath
|
|
743
|
+
? new URL(targetUrl).pathname.replace(/\/+$/, "") || "/"
|
|
744
|
+
: null;
|
|
744
745
|
|
|
745
746
|
return withAccountPage(
|
|
746
747
|
accountId,
|
|
@@ -755,10 +756,11 @@ async function withQwenBrowserPage<T>(
|
|
|
755
756
|
// Navigate below when the current page has no usable URL.
|
|
756
757
|
}
|
|
757
758
|
|
|
758
|
-
|
|
759
|
+
const needsNavigation =
|
|
759
760
|
currentOrigin !== targetOrigin ||
|
|
760
|
-
(normalizedTargetPath && currentPath !== normalizedTargetPath)
|
|
761
|
-
|
|
761
|
+
(normalizedTargetPath !== null && currentPath !== normalizedTargetPath);
|
|
762
|
+
|
|
763
|
+
if (needsNavigation) {
|
|
762
764
|
await page.goto(targetUrl, {
|
|
763
765
|
waitUntil: "domcontentloaded",
|
|
764
766
|
timeout: Math.min(config.timeouts.navigation, operationTimeoutMs),
|
|
@@ -773,44 +775,7 @@ async function withQwenBrowserPage<T>(
|
|
|
773
775
|
);
|
|
774
776
|
}
|
|
775
777
|
|
|
776
|
-
|
|
777
|
-
accountId: string,
|
|
778
|
-
fn: (page: Page) => Promise<T>,
|
|
779
|
-
operationTimeoutMs = config.timeouts.page,
|
|
780
|
-
recoverOnTimeout = true,
|
|
781
|
-
): Promise<T> {
|
|
782
|
-
return withQwenBrowserPage(
|
|
783
|
-
accountId,
|
|
784
|
-
async (page) => {
|
|
785
|
-
try {
|
|
786
|
-
return await fn(page);
|
|
787
|
-
} finally {
|
|
788
|
-
if (!page.isClosed()) {
|
|
789
|
-
try {
|
|
790
|
-
const currentUrl = new URL(page.url());
|
|
791
|
-
const currentPath = currentUrl.pathname.replace(/\/+$/, "") || "/";
|
|
792
|
-
if (currentUrl.origin !== qwenOrigin() || currentPath !== "/") {
|
|
793
|
-
await page.goto(qwenUrl("/"), {
|
|
794
|
-
waitUntil: "domcontentloaded",
|
|
795
|
-
timeout: Math.min(config.timeouts.navigation, operationTimeoutMs),
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
} catch (error) {
|
|
799
|
-
// Do not mask the personalization request result if restoring the
|
|
800
|
-
// normal chat page fails; the next normal operation will retry it.
|
|
801
|
-
logger.warn("[Qwen] Could not restore chat page after personalization", {
|
|
802
|
-
accountId,
|
|
803
|
-
error: error instanceof Error ? error.message : String(error),
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
},
|
|
809
|
-
"/settings/personalization",
|
|
810
|
-
operationTimeoutMs,
|
|
811
|
-
recoverOnTimeout,
|
|
812
|
-
);
|
|
813
|
-
}
|
|
778
|
+
|
|
814
779
|
|
|
815
780
|
/**
|
|
816
781
|
* Build minimal headers for browser-side fetch. The browser automatically
|
package/src/tools/parser.ts
CHANGED
|
@@ -302,6 +302,14 @@ function advanceMarkdownCodeState(
|
|
|
302
302
|
let delimiterLength = initialDelimiterLength;
|
|
303
303
|
|
|
304
304
|
for (let i = 0; i < text.length;) {
|
|
305
|
+
// Inline code spans (1 or 2 backticks) cannot cross line breaks per CommonMark §6.1.
|
|
306
|
+
// Only fenced code blocks (3+ backticks) span multiple lines.
|
|
307
|
+
if (text[i] === "\n" && delimiterLength < 3) {
|
|
308
|
+
delimiterLength = 0;
|
|
309
|
+
i++;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
305
313
|
if (text[i] !== "`") {
|
|
306
314
|
i++;
|
|
307
315
|
continue;
|
|
@@ -320,7 +328,6 @@ function advanceMarkdownCodeState(
|
|
|
320
328
|
|
|
321
329
|
i += runLength;
|
|
322
330
|
}
|
|
323
|
-
|
|
324
331
|
return delimiterLength;
|
|
325
332
|
}
|
|
326
333
|
|
|
@@ -331,6 +338,13 @@ function findNextToolOpenTagOutsideMarkdownCode(
|
|
|
331
338
|
let delimiterLength = initialDelimiterLength;
|
|
332
339
|
|
|
333
340
|
for (let i = 0; i < buffer.length;) {
|
|
341
|
+
// Inline code spans (1 or 2 backticks) cannot cross line breaks per CommonMark §6.1.
|
|
342
|
+
if (buffer[i] === "\n" && delimiterLength < 3) {
|
|
343
|
+
delimiterLength = 0;
|
|
344
|
+
i++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
334
348
|
if (buffer[i] === "`") {
|
|
335
349
|
let runLength = 1;
|
|
336
350
|
while (i + runLength < buffer.length && buffer[i + runLength] === "`") {
|
|
@@ -346,7 +360,6 @@ function findNextToolOpenTagOutsideMarkdownCode(
|
|
|
346
360
|
i += runLength;
|
|
347
361
|
continue;
|
|
348
362
|
}
|
|
349
|
-
|
|
350
363
|
if (delimiterLength === 0 && buffer[i] === "<") {
|
|
351
364
|
const sub = buffer.substring(i);
|
|
352
365
|
for (const name of getOpenNames()) {
|
|
@@ -371,6 +384,13 @@ function findPartialToolOpenIndexOutsideMarkdownCode(
|
|
|
371
384
|
const openNames = getOpenNames();
|
|
372
385
|
|
|
373
386
|
for (let i = 0; i < buffer.length;) {
|
|
387
|
+
// Inline code spans (1 or 2 backticks) cannot cross line breaks per CommonMark §6.1.
|
|
388
|
+
if (buffer[i] === "\n" && delimiterLength < 3) {
|
|
389
|
+
delimiterLength = 0;
|
|
390
|
+
i++;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
374
394
|
if (buffer[i] === "`") {
|
|
375
395
|
let runLength = 1;
|
|
376
396
|
while (i + runLength < buffer.length && buffer[i + runLength] === "`") {
|
|
@@ -386,7 +406,6 @@ function findPartialToolOpenIndexOutsideMarkdownCode(
|
|
|
386
406
|
i += runLength;
|
|
387
407
|
continue;
|
|
388
408
|
}
|
|
389
|
-
|
|
390
409
|
if (delimiterLength === 0 && buffer[i] === "<") {
|
|
391
410
|
const tailLower = buffer.substring(i).toLowerCase();
|
|
392
411
|
if (!tailLower.includes(">")) {
|