qwenproxy-cli 1.0.0 → 1.0.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/LICENSE +14 -14
- package/README.md +906 -906
- package/bin/qwenproxy.js +5 -1
- package/package.json +77 -78
- package/src/api/error-classifier.ts +159 -159
- package/src/api/error-helpers.ts +118 -118
- package/src/api/models.ts +261 -261
- package/src/api/server.ts +860 -859
- package/src/cache/memory-cache.ts +385 -385
- package/src/clean-cache.ts +204 -204
- package/src/core/account-concurrency.ts +671 -671
- package/src/core/account-manager.ts +301 -297
- package/src/core/account-priority.ts +163 -163
- package/src/core/accounts.ts +186 -186
- package/src/core/config.ts +383 -383
- package/src/core/crypto-utils.ts +79 -79
- package/src/core/database.ts +276 -276
- package/src/core/errors.ts +118 -118
- package/src/core/logger.ts +269 -269
- package/src/core/memory-usage.ts +84 -84
- package/src/core/metrics.ts +291 -291
- package/src/core/model-alias.ts +77 -77
- package/src/core/model-registry.ts +544 -544
- package/src/core/mutex.ts +119 -119
- package/src/core/paths.ts +199 -199
- package/src/core/prompt-limits.ts +214 -214
- package/src/core/reasoning-effort.ts +102 -102
- package/src/core/stream-registry.ts +96 -96
- package/src/core/waf-isolation.ts +117 -117
- package/src/core/watchdog.ts +195 -195
- package/src/delete-chats.ts +23 -23
- package/src/index.ts +65 -64
- package/src/login.ts +147 -147
- package/src/reset-cooldowns.ts +11 -11
- package/src/routes/anthropic/index.ts +355 -355
- package/src/routes/anthropic/translate.ts +522 -522
- package/src/routes/anthropic/types.ts +154 -154
- package/src/routes/anthropic/validation.ts +144 -144
- package/src/routes/chat/account.ts +1817 -1817
- package/src/routes/chat/context.ts +241 -241
- package/src/routes/chat/errors.ts +85 -85
- package/src/routes/chat/helpers.ts +268 -268
- package/src/routes/chat/index.ts +618 -618
- package/src/routes/chat/media.ts +285 -285
- package/src/routes/chat/retry-policy.ts +754 -754
- package/src/routes/chat/stop.ts +98 -98
- package/src/routes/chat/streaming.ts +2710 -2710
- package/src/routes/chat/validation.ts +526 -526
- package/src/routes/chat.ts +2 -2
- package/src/routes/completions.ts +290 -290
- package/src/routes/images.ts +139 -139
- package/src/routes/responses/adapter.ts +503 -503
- package/src/routes/responses/index.ts +405 -405
- package/src/routes/responses/state.ts +230 -230
- package/src/routes/responses/streaming.ts +528 -528
- package/src/routes/responses/types.ts +285 -285
- package/src/routes/responses/validation.ts +202 -202
- package/src/routes/upload.ts +731 -731
- package/src/routes/videos.ts +214 -214
- package/src/services/auth-playwright.ts +173 -173
- package/src/services/captcha-coordinator.ts +161 -161
- package/src/services/captcha-solver.ts +553 -553
- package/src/services/chat-cleanup.ts +80 -80
- package/src/services/context-meter.ts +317 -317
- package/src/services/fingerprint.ts +242 -242
- package/src/services/human-behavior.ts +173 -173
- package/src/services/media-generation.ts +1748 -1748
- package/src/services/playwright.ts +2878 -2800
- package/src/services/qwen-chat-pool.ts +345 -345
- package/src/services/qwen-errors.ts +133 -133
- package/src/services/qwen-headers.ts +79 -79
- package/src/services/qwen-thread-state.ts +393 -393
- package/src/services/qwen-url.ts +19 -19
- package/src/services/qwen.ts +3126 -3126
- package/src/services/session-keeper.ts +88 -88
- package/src/services/token-estimation-metrics.ts +118 -118
- package/src/sync/claude-code.ts +75 -75
- package/src/sync/codex.ts +123 -123
- package/src/sync/index.ts +362 -362
- package/src/sync/omp.ts +105 -105
- package/src/sync/opencode.ts +214 -214
- package/src/sync/types.ts +53 -53
- package/src/sync/utils.ts +27 -27
- package/src/sync-clients.ts +189 -189
- package/src/tools/instructions.ts +137 -137
- package/src/tools/manifest.ts +81 -81
- package/src/tools/parser.ts +2989 -2989
- package/src/tools/toolcall-tags.ts +142 -142
- package/src/tui/app.ts +259 -264
- package/src/tui/index.ts +61 -61
- package/src/tui/markdown.ts +258 -258
- package/src/tui/proxy-client.ts +331 -326
- package/src/tui/screen.ts +294 -278
- package/src/tui/server-manager.ts +270 -270
- package/src/tui/theme.ts +432 -432
- package/src/tui/types.ts +33 -33
- package/src/tui/views/accounts-view.ts +656 -656
- package/src/tui/views/chat-view.ts +1018 -823
- package/src/tui/views/logs-view.ts +479 -413
- package/src/tui/views/status-view.ts +204 -204
- package/src/tui/views/storage-view.ts +304 -291
- package/src/tui/views/sync-view.ts +409 -409
- package/src/types/ali-oss.d.ts +32 -32
- package/src/update-cli.ts +121 -0
- package/src/utils/context-truncation.ts +84 -84
- package/src/utils/json.ts +380 -380
- package/src/utils/session-id.ts +37 -37
- package/src/utils/tool-call-guard.ts +84 -84
- package/src/utils/types.ts +109 -109
|
@@ -1,345 +1,345 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
|
-
import { getQwenHeaders, isAuthMockEnabled } from "./auth-playwright.ts";
|
|
3
|
-
import { config, type ChatMode } from "../core/config.ts";
|
|
4
|
-
import { logger, isToolcallDebugEnabled } from "../core/logger.ts";
|
|
5
|
-
import {
|
|
6
|
-
computeQuotaCooldownMs,
|
|
7
|
-
markAccountRateLimited,
|
|
8
|
-
} from "../core/account-manager.ts";
|
|
9
|
-
import { mapClientModelToQwen } from "../core/model-alias.ts";
|
|
10
|
-
import { qwenUrl } from "./qwen-url.ts";
|
|
11
|
-
import { QwenUpstreamError } from "./qwen-errors.ts";
|
|
12
|
-
import {
|
|
13
|
-
requestQwenTextInBrowser,
|
|
14
|
-
buildCapturedQwenHeaders,
|
|
15
|
-
readJsonTextResponse,
|
|
16
|
-
} from "./qwen.ts";
|
|
17
|
-
|
|
18
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
-
|
|
20
|
-
async function createQwenChatSession(
|
|
21
|
-
headers: Record<string, string>,
|
|
22
|
-
model: string,
|
|
23
|
-
accountId?: string,
|
|
24
|
-
chatMode: ChatMode = "thread",
|
|
25
|
-
): Promise<string> {
|
|
26
|
-
if (isAuthMockEnabled()) {
|
|
27
|
-
return process.env.TEST_SESSION_ID || "mock-session";
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const response = await requestQwenTextInBrowser(
|
|
31
|
-
accountId,
|
|
32
|
-
"POST",
|
|
33
|
-
"/api/v2/chats/new",
|
|
34
|
-
buildCapturedQwenHeaders(headers, {
|
|
35
|
-
referer: qwenUrl("/"),
|
|
36
|
-
}),
|
|
37
|
-
JSON.stringify(buildChatNewBody(model, chatMode)),
|
|
38
|
-
{ referrer: qwenUrl("/") },
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
const { raw, json } = await readJsonTextResponse(response, {
|
|
42
|
-
strict: true,
|
|
43
|
-
});
|
|
44
|
-
if (!response.ok) {
|
|
45
|
-
throw new QwenUpstreamError(
|
|
46
|
-
`Qwen create chat failed: ${response.status} ${response.statusText} - ${raw.substring(0, 300)}`,
|
|
47
|
-
"CreateChatFailed",
|
|
48
|
-
response.status >= 500 ? 502 : response.status,
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const chatId =
|
|
53
|
-
json?.chat_id ||
|
|
54
|
-
json?.id ||
|
|
55
|
-
json?.data?.chat_id ||
|
|
56
|
-
json?.data?.id ||
|
|
57
|
-
json?.data?.chat?.id;
|
|
58
|
-
|
|
59
|
-
if (!chatId || typeof chatId !== "string") {
|
|
60
|
-
throw new QwenUpstreamError(
|
|
61
|
-
`Qwen create chat returned unexpected payload: ${raw.substring(0, 300)}`,
|
|
62
|
-
"CreateChatInvalidResponse",
|
|
63
|
-
502,
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return chatId;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Body for POST /api/v2/chats/new, matching the real web client exactly
|
|
72
|
-
* (verified in HAR): chatId:"" instead of a title. The API then defaults the
|
|
73
|
-
* list title to "New chat", which isReusableUnusedChatTitle accepts so the
|
|
74
|
-
* warm pool can still find and recycle the chat.
|
|
75
|
-
*/
|
|
76
|
-
export function buildChatNewBody(
|
|
77
|
-
model: string,
|
|
78
|
-
chatMode: ChatMode = "thread",
|
|
79
|
-
): Record<string, unknown> {
|
|
80
|
-
return {
|
|
81
|
-
chatId: "",
|
|
82
|
-
models: [model],
|
|
83
|
-
project_id: "",
|
|
84
|
-
timestamp: Date.now(),
|
|
85
|
-
chat_type: "t2t",
|
|
86
|
-
// thread → normal (persisted), temp → local (ephemeral, not listed).
|
|
87
|
-
chat_mode: chatMode === "temp" ? "local" : "normal",
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* True when a chat is an API-default-titled, never-messaged chat that the
|
|
93
|
-
* warm pool may recycle. Both defaults exist in practice: chats created by
|
|
94
|
-
* this project (title:"Nova Conversa") and chats created by the web client
|
|
95
|
-
* without a title (API default "New chat").
|
|
96
|
-
*/
|
|
97
|
-
export function isReusableUnusedChatTitle(
|
|
98
|
-
title: unknown,
|
|
99
|
-
): title is string {
|
|
100
|
-
return title === "Nova Conversa" || title === "New chat";
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Fetch existing unused chats from the Qwen API.
|
|
105
|
-
* Unused chats keep their API-default title ("Nova Conversa" or "New chat")
|
|
106
|
-
* and created_at === updated_at.
|
|
107
|
-
*/
|
|
108
|
-
async function fetchUnusedChats(
|
|
109
|
-
headers: Record<string, string>,
|
|
110
|
-
accountId?: string,
|
|
111
|
-
): Promise<string[]> {
|
|
112
|
-
try {
|
|
113
|
-
const response = await requestQwenTextInBrowser(
|
|
114
|
-
accountId,
|
|
115
|
-
"GET",
|
|
116
|
-
"/api/v2/chats/?page=1&exclude_project=true",
|
|
117
|
-
buildCapturedQwenHeaders(headers, {
|
|
118
|
-
extra: {
|
|
119
|
-
accept: "application/json, text/plain, */*",
|
|
120
|
-
"x-request-id": crypto.randomUUID(),
|
|
121
|
-
source: "web",
|
|
122
|
-
},
|
|
123
|
-
}),
|
|
124
|
-
undefined,
|
|
125
|
-
{ referrer: qwenUrl("/settings/chats") },
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
if (!response.ok) return [];
|
|
129
|
-
|
|
130
|
-
const json: any = await response.json().catch(() => null);
|
|
131
|
-
if (!json?.success || !Array.isArray(json.data)) return [];
|
|
132
|
-
|
|
133
|
-
const unused: string[] = [];
|
|
134
|
-
for (const chat of json.data) {
|
|
135
|
-
if (
|
|
136
|
-
isReusableUnusedChatTitle(chat.title) &&
|
|
137
|
-
chat.created_at === chat.updated_at
|
|
138
|
-
) {
|
|
139
|
-
unused.push(chat.id);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return unused;
|
|
143
|
-
} catch {
|
|
144
|
-
return [];
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const precreatedChatSessions = new Map<string, string[]>();
|
|
149
|
-
const precreatingChatSessions = new Set<string>();
|
|
150
|
-
const inFlightWarmChats = new Set<string>();
|
|
151
|
-
const WARM_POOL_LOW_WATER = 3;
|
|
152
|
-
|
|
153
|
-
function warmChatKey(
|
|
154
|
-
accountId: string | undefined,
|
|
155
|
-
model: string,
|
|
156
|
-
chatId: string,
|
|
157
|
-
) {
|
|
158
|
-
return `${accountId || "global"}:${model}:${chatId}`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function markWarmChatInFlight(
|
|
162
|
-
accountId: string | undefined,
|
|
163
|
-
model: string,
|
|
164
|
-
chatId: string,
|
|
165
|
-
): void {
|
|
166
|
-
inFlightWarmChats.add(warmChatKey(accountId, model, chatId));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function releaseWarmChat(
|
|
170
|
-
accountId: string | undefined,
|
|
171
|
-
model: string,
|
|
172
|
-
chatId: string,
|
|
173
|
-
): void {
|
|
174
|
-
inFlightWarmChats.delete(warmChatKey(accountId, model, chatId));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function isWarmChatInFlight(
|
|
178
|
-
accountId: string | undefined,
|
|
179
|
-
model: string,
|
|
180
|
-
chatId: string,
|
|
181
|
-
): boolean {
|
|
182
|
-
return inFlightWarmChats.has(warmChatKey(accountId, model, chatId));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function chatPoolKey(accountId: string | undefined, model: string): string {
|
|
186
|
-
return `${accountId || "global"}:${model}`;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function isQwenChatPoolEnabled(): boolean {
|
|
190
|
-
return (
|
|
191
|
-
config.qwen.chatPoolSize > 0 &&
|
|
192
|
-
!isAuthMockEnabled() &&
|
|
193
|
-
!config.qwen.personalizationFromRequest
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export async function acquireNewQwenChatSession(
|
|
198
|
-
headers: Record<string, string>,
|
|
199
|
-
model: string,
|
|
200
|
-
accountId?: string,
|
|
201
|
-
chatMode: ChatMode = "thread",
|
|
202
|
-
): Promise<{ chatId: string; leasedFromPool: boolean }> {
|
|
203
|
-
if (isQwenChatPoolEnabled() && chatMode !== "temp") {
|
|
204
|
-
const key = chatPoolKey(accountId, model);
|
|
205
|
-
const pooled = precreatedChatSessions.get(key);
|
|
206
|
-
const chatId = pooled?.shift();
|
|
207
|
-
|
|
208
|
-
if (chatId) {
|
|
209
|
-
logger.debug("[Qwen] using pooled chat", {
|
|
210
|
-
accountId: accountId || "global",
|
|
211
|
-
model,
|
|
212
|
-
chatId,
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
// Proactive refill when pool drops below low-water mark
|
|
216
|
-
markWarmChatInFlight(accountId, model, chatId);
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
(pooled?.length ?? 0) < WARM_POOL_LOW_WATER &&
|
|
220
|
-
!precreatingChatSessions.has(key)
|
|
221
|
-
) {
|
|
222
|
-
void refillQwenChatPool(headers, model, accountId);
|
|
223
|
-
} else {
|
|
224
|
-
void scheduleQwenChatPoolRefill(headers, model, accountId);
|
|
225
|
-
}
|
|
226
|
-
return { chatId, leasedFromPool: true };
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const created = await createQwenChatSession(headers, model, accountId, chatMode);
|
|
231
|
-
logger.debug("[Qwen] created fresh chat", {
|
|
232
|
-
accountId: accountId || "global",
|
|
233
|
-
model,
|
|
234
|
-
chatId: created,
|
|
235
|
-
});
|
|
236
|
-
if (isQwenChatPoolEnabled() && chatMode !== "temp") {
|
|
237
|
-
void scheduleQwenChatPoolRefill(headers, model, accountId);
|
|
238
|
-
}
|
|
239
|
-
return { chatId: created, leasedFromPool: false };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
async function refillQwenChatPool(
|
|
243
|
-
headers: Record<string, string>,
|
|
244
|
-
model: string,
|
|
245
|
-
accountId?: string,
|
|
246
|
-
): Promise<void> {
|
|
247
|
-
if (!isQwenChatPoolEnabled()) return;
|
|
248
|
-
const targetSize = config.qwen.chatPoolSize;
|
|
249
|
-
|
|
250
|
-
const key = chatPoolKey(accountId, model);
|
|
251
|
-
const pooled = precreatedChatSessions.get(key) ?? [];
|
|
252
|
-
if (pooled.length >= targetSize || precreatingChatSessions.has(key)) return;
|
|
253
|
-
|
|
254
|
-
precreatingChatSessions.add(key);
|
|
255
|
-
try {
|
|
256
|
-
// Reuse existing unused chats before creating new ones
|
|
257
|
-
const existingIds = new Set(precreatedChatSessions.get(key) ?? []);
|
|
258
|
-
let reused = 0;
|
|
259
|
-
try {
|
|
260
|
-
const unusedChats = await fetchUnusedChats(headers, accountId);
|
|
261
|
-
for (const chatId of unusedChats) {
|
|
262
|
-
if ((precreatedChatSessions.get(key)?.length ?? 0) >= targetSize) break;
|
|
263
|
-
if (existingIds.has(chatId)) continue;
|
|
264
|
-
if (isWarmChatInFlight(accountId, model, chatId)) continue;
|
|
265
|
-
const current = precreatedChatSessions.get(key) ?? [];
|
|
266
|
-
current.push(chatId);
|
|
267
|
-
precreatedChatSessions.set(key, current);
|
|
268
|
-
existingIds.add(chatId);
|
|
269
|
-
reused++;
|
|
270
|
-
}
|
|
271
|
-
if (reused > 0) {
|
|
272
|
-
console.log(
|
|
273
|
-
`[WarmPool] Reused ${reused} existing unused chats for ${accountId || "global"}`,
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
} catch (err: any) {
|
|
277
|
-
console.warn(
|
|
278
|
-
`[WarmPool] Failed to fetch unused chats for ${accountId || "global"}:`,
|
|
279
|
-
err.message,
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Create remaining chats needed
|
|
284
|
-
let isFirst = true;
|
|
285
|
-
while ((precreatedChatSessions.get(key)?.length ?? 0) < targetSize) {
|
|
286
|
-
if (!isFirst) {
|
|
287
|
-
// Reduced delay for faster warm pool filling (upstream: 3806cf6)
|
|
288
|
-
await sleep(300 + Math.floor(Math.random() * 700));
|
|
289
|
-
}
|
|
290
|
-
isFirst = false;
|
|
291
|
-
const chatId = await createQwenChatSession(headers, model, accountId);
|
|
292
|
-
const current = precreatedChatSessions.get(key) ?? [];
|
|
293
|
-
current.push(chatId);
|
|
294
|
-
precreatedChatSessions.set(key, current);
|
|
295
|
-
}
|
|
296
|
-
} catch (err: any) {
|
|
297
|
-
// Mark account as rate-limited if chat creation fails with RateLimited error
|
|
298
|
-
if (err instanceof QwenUpstreamError) {
|
|
299
|
-
if (err.upstreamCode === "RateLimited" || err.upstreamStatus === 429) {
|
|
300
|
-
// Daily quota resets at the next UTC midnight — never the literal
|
|
301
|
-
// "Wait about N hour(s)" hint (near midnight it over-blocks by ~22h).
|
|
302
|
-
markAccountRateLimited(
|
|
303
|
-
accountId || "global",
|
|
304
|
-
computeQuotaCooldownMs(Date.now()),
|
|
305
|
-
"RateLimited",
|
|
306
|
-
);
|
|
307
|
-
console.warn(
|
|
308
|
-
`[WarmPool] Account ${accountId || "global"} rate-limited during chat creation. Marked for cooldown.`,
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
if (isToolcallDebugEnabled()) {
|
|
313
|
-
logger.debug("[Qwen] Failed to refill chat pool", {
|
|
314
|
-
accountId: accountId || "global",
|
|
315
|
-
model,
|
|
316
|
-
error: err instanceof Error ? err.message : String(err),
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
} finally {
|
|
320
|
-
precreatingChatSessions.delete(key);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function scheduleQwenChatPoolRefill(
|
|
325
|
-
headers: Record<string, string>,
|
|
326
|
-
model: string,
|
|
327
|
-
accountId?: string,
|
|
328
|
-
): void {
|
|
329
|
-
setTimeout(() => {
|
|
330
|
-
void refillQwenChatPool(headers, model, accountId);
|
|
331
|
-
}, 250);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
export async function warmQwenChatPool(
|
|
335
|
-
accountId: string | undefined,
|
|
336
|
-
modelId: string,
|
|
337
|
-
): Promise<void> {
|
|
338
|
-
if (!isQwenChatPoolEnabled()) return;
|
|
339
|
-
const { headers } = await getQwenHeaders(false, accountId);
|
|
340
|
-
await refillQwenChatPool(
|
|
341
|
-
headers,
|
|
342
|
-
mapClientModelToQwen(modelId),
|
|
343
|
-
accountId,
|
|
344
|
-
);
|
|
345
|
-
}
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { getQwenHeaders, isAuthMockEnabled } from "./auth-playwright.ts";
|
|
3
|
+
import { config, type ChatMode } from "../core/config.ts";
|
|
4
|
+
import { logger, isToolcallDebugEnabled } from "../core/logger.ts";
|
|
5
|
+
import {
|
|
6
|
+
computeQuotaCooldownMs,
|
|
7
|
+
markAccountRateLimited,
|
|
8
|
+
} from "../core/account-manager.ts";
|
|
9
|
+
import { mapClientModelToQwen } from "../core/model-alias.ts";
|
|
10
|
+
import { qwenUrl } from "./qwen-url.ts";
|
|
11
|
+
import { QwenUpstreamError } from "./qwen-errors.ts";
|
|
12
|
+
import {
|
|
13
|
+
requestQwenTextInBrowser,
|
|
14
|
+
buildCapturedQwenHeaders,
|
|
15
|
+
readJsonTextResponse,
|
|
16
|
+
} from "./qwen.ts";
|
|
17
|
+
|
|
18
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
|
|
20
|
+
async function createQwenChatSession(
|
|
21
|
+
headers: Record<string, string>,
|
|
22
|
+
model: string,
|
|
23
|
+
accountId?: string,
|
|
24
|
+
chatMode: ChatMode = "thread",
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
if (isAuthMockEnabled()) {
|
|
27
|
+
return process.env.TEST_SESSION_ID || "mock-session";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const response = await requestQwenTextInBrowser(
|
|
31
|
+
accountId,
|
|
32
|
+
"POST",
|
|
33
|
+
"/api/v2/chats/new",
|
|
34
|
+
buildCapturedQwenHeaders(headers, {
|
|
35
|
+
referer: qwenUrl("/"),
|
|
36
|
+
}),
|
|
37
|
+
JSON.stringify(buildChatNewBody(model, chatMode)),
|
|
38
|
+
{ referrer: qwenUrl("/") },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const { raw, json } = await readJsonTextResponse(response, {
|
|
42
|
+
strict: true,
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new QwenUpstreamError(
|
|
46
|
+
`Qwen create chat failed: ${response.status} ${response.statusText} - ${raw.substring(0, 300)}`,
|
|
47
|
+
"CreateChatFailed",
|
|
48
|
+
response.status >= 500 ? 502 : response.status,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const chatId =
|
|
53
|
+
json?.chat_id ||
|
|
54
|
+
json?.id ||
|
|
55
|
+
json?.data?.chat_id ||
|
|
56
|
+
json?.data?.id ||
|
|
57
|
+
json?.data?.chat?.id;
|
|
58
|
+
|
|
59
|
+
if (!chatId || typeof chatId !== "string") {
|
|
60
|
+
throw new QwenUpstreamError(
|
|
61
|
+
`Qwen create chat returned unexpected payload: ${raw.substring(0, 300)}`,
|
|
62
|
+
"CreateChatInvalidResponse",
|
|
63
|
+
502,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return chatId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Body for POST /api/v2/chats/new, matching the real web client exactly
|
|
72
|
+
* (verified in HAR): chatId:"" instead of a title. The API then defaults the
|
|
73
|
+
* list title to "New chat", which isReusableUnusedChatTitle accepts so the
|
|
74
|
+
* warm pool can still find and recycle the chat.
|
|
75
|
+
*/
|
|
76
|
+
export function buildChatNewBody(
|
|
77
|
+
model: string,
|
|
78
|
+
chatMode: ChatMode = "thread",
|
|
79
|
+
): Record<string, unknown> {
|
|
80
|
+
return {
|
|
81
|
+
chatId: "",
|
|
82
|
+
models: [model],
|
|
83
|
+
project_id: "",
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
chat_type: "t2t",
|
|
86
|
+
// thread → normal (persisted), temp → local (ephemeral, not listed).
|
|
87
|
+
chat_mode: chatMode === "temp" ? "local" : "normal",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when a chat is an API-default-titled, never-messaged chat that the
|
|
93
|
+
* warm pool may recycle. Both defaults exist in practice: chats created by
|
|
94
|
+
* this project (title:"Nova Conversa") and chats created by the web client
|
|
95
|
+
* without a title (API default "New chat").
|
|
96
|
+
*/
|
|
97
|
+
export function isReusableUnusedChatTitle(
|
|
98
|
+
title: unknown,
|
|
99
|
+
): title is string {
|
|
100
|
+
return title === "Nova Conversa" || title === "New chat";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Fetch existing unused chats from the Qwen API.
|
|
105
|
+
* Unused chats keep their API-default title ("Nova Conversa" or "New chat")
|
|
106
|
+
* and created_at === updated_at.
|
|
107
|
+
*/
|
|
108
|
+
async function fetchUnusedChats(
|
|
109
|
+
headers: Record<string, string>,
|
|
110
|
+
accountId?: string,
|
|
111
|
+
): Promise<string[]> {
|
|
112
|
+
try {
|
|
113
|
+
const response = await requestQwenTextInBrowser(
|
|
114
|
+
accountId,
|
|
115
|
+
"GET",
|
|
116
|
+
"/api/v2/chats/?page=1&exclude_project=true",
|
|
117
|
+
buildCapturedQwenHeaders(headers, {
|
|
118
|
+
extra: {
|
|
119
|
+
accept: "application/json, text/plain, */*",
|
|
120
|
+
"x-request-id": crypto.randomUUID(),
|
|
121
|
+
source: "web",
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
undefined,
|
|
125
|
+
{ referrer: qwenUrl("/settings/chats") },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (!response.ok) return [];
|
|
129
|
+
|
|
130
|
+
const json: any = await response.json().catch(() => null);
|
|
131
|
+
if (!json?.success || !Array.isArray(json.data)) return [];
|
|
132
|
+
|
|
133
|
+
const unused: string[] = [];
|
|
134
|
+
for (const chat of json.data) {
|
|
135
|
+
if (
|
|
136
|
+
isReusableUnusedChatTitle(chat.title) &&
|
|
137
|
+
chat.created_at === chat.updated_at
|
|
138
|
+
) {
|
|
139
|
+
unused.push(chat.id);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return unused;
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const precreatedChatSessions = new Map<string, string[]>();
|
|
149
|
+
const precreatingChatSessions = new Set<string>();
|
|
150
|
+
const inFlightWarmChats = new Set<string>();
|
|
151
|
+
const WARM_POOL_LOW_WATER = 3;
|
|
152
|
+
|
|
153
|
+
function warmChatKey(
|
|
154
|
+
accountId: string | undefined,
|
|
155
|
+
model: string,
|
|
156
|
+
chatId: string,
|
|
157
|
+
) {
|
|
158
|
+
return `${accountId || "global"}:${model}:${chatId}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function markWarmChatInFlight(
|
|
162
|
+
accountId: string | undefined,
|
|
163
|
+
model: string,
|
|
164
|
+
chatId: string,
|
|
165
|
+
): void {
|
|
166
|
+
inFlightWarmChats.add(warmChatKey(accountId, model, chatId));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function releaseWarmChat(
|
|
170
|
+
accountId: string | undefined,
|
|
171
|
+
model: string,
|
|
172
|
+
chatId: string,
|
|
173
|
+
): void {
|
|
174
|
+
inFlightWarmChats.delete(warmChatKey(accountId, model, chatId));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isWarmChatInFlight(
|
|
178
|
+
accountId: string | undefined,
|
|
179
|
+
model: string,
|
|
180
|
+
chatId: string,
|
|
181
|
+
): boolean {
|
|
182
|
+
return inFlightWarmChats.has(warmChatKey(accountId, model, chatId));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function chatPoolKey(accountId: string | undefined, model: string): string {
|
|
186
|
+
return `${accountId || "global"}:${model}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isQwenChatPoolEnabled(): boolean {
|
|
190
|
+
return (
|
|
191
|
+
config.qwen.chatPoolSize > 0 &&
|
|
192
|
+
!isAuthMockEnabled() &&
|
|
193
|
+
!config.qwen.personalizationFromRequest
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function acquireNewQwenChatSession(
|
|
198
|
+
headers: Record<string, string>,
|
|
199
|
+
model: string,
|
|
200
|
+
accountId?: string,
|
|
201
|
+
chatMode: ChatMode = "thread",
|
|
202
|
+
): Promise<{ chatId: string; leasedFromPool: boolean }> {
|
|
203
|
+
if (isQwenChatPoolEnabled() && chatMode !== "temp") {
|
|
204
|
+
const key = chatPoolKey(accountId, model);
|
|
205
|
+
const pooled = precreatedChatSessions.get(key);
|
|
206
|
+
const chatId = pooled?.shift();
|
|
207
|
+
|
|
208
|
+
if (chatId) {
|
|
209
|
+
logger.debug("[Qwen] using pooled chat", {
|
|
210
|
+
accountId: accountId || "global",
|
|
211
|
+
model,
|
|
212
|
+
chatId,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Proactive refill when pool drops below low-water mark
|
|
216
|
+
markWarmChatInFlight(accountId, model, chatId);
|
|
217
|
+
|
|
218
|
+
if (
|
|
219
|
+
(pooled?.length ?? 0) < WARM_POOL_LOW_WATER &&
|
|
220
|
+
!precreatingChatSessions.has(key)
|
|
221
|
+
) {
|
|
222
|
+
void refillQwenChatPool(headers, model, accountId);
|
|
223
|
+
} else {
|
|
224
|
+
void scheduleQwenChatPoolRefill(headers, model, accountId);
|
|
225
|
+
}
|
|
226
|
+
return { chatId, leasedFromPool: true };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const created = await createQwenChatSession(headers, model, accountId, chatMode);
|
|
231
|
+
logger.debug("[Qwen] created fresh chat", {
|
|
232
|
+
accountId: accountId || "global",
|
|
233
|
+
model,
|
|
234
|
+
chatId: created,
|
|
235
|
+
});
|
|
236
|
+
if (isQwenChatPoolEnabled() && chatMode !== "temp") {
|
|
237
|
+
void scheduleQwenChatPoolRefill(headers, model, accountId);
|
|
238
|
+
}
|
|
239
|
+
return { chatId: created, leasedFromPool: false };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function refillQwenChatPool(
|
|
243
|
+
headers: Record<string, string>,
|
|
244
|
+
model: string,
|
|
245
|
+
accountId?: string,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
if (!isQwenChatPoolEnabled()) return;
|
|
248
|
+
const targetSize = config.qwen.chatPoolSize;
|
|
249
|
+
|
|
250
|
+
const key = chatPoolKey(accountId, model);
|
|
251
|
+
const pooled = precreatedChatSessions.get(key) ?? [];
|
|
252
|
+
if (pooled.length >= targetSize || precreatingChatSessions.has(key)) return;
|
|
253
|
+
|
|
254
|
+
precreatingChatSessions.add(key);
|
|
255
|
+
try {
|
|
256
|
+
// Reuse existing unused chats before creating new ones
|
|
257
|
+
const existingIds = new Set(precreatedChatSessions.get(key) ?? []);
|
|
258
|
+
let reused = 0;
|
|
259
|
+
try {
|
|
260
|
+
const unusedChats = await fetchUnusedChats(headers, accountId);
|
|
261
|
+
for (const chatId of unusedChats) {
|
|
262
|
+
if ((precreatedChatSessions.get(key)?.length ?? 0) >= targetSize) break;
|
|
263
|
+
if (existingIds.has(chatId)) continue;
|
|
264
|
+
if (isWarmChatInFlight(accountId, model, chatId)) continue;
|
|
265
|
+
const current = precreatedChatSessions.get(key) ?? [];
|
|
266
|
+
current.push(chatId);
|
|
267
|
+
precreatedChatSessions.set(key, current);
|
|
268
|
+
existingIds.add(chatId);
|
|
269
|
+
reused++;
|
|
270
|
+
}
|
|
271
|
+
if (reused > 0) {
|
|
272
|
+
console.log(
|
|
273
|
+
`[WarmPool] Reused ${reused} existing unused chats for ${accountId || "global"}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
} catch (err: any) {
|
|
277
|
+
console.warn(
|
|
278
|
+
`[WarmPool] Failed to fetch unused chats for ${accountId || "global"}:`,
|
|
279
|
+
err.message,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Create remaining chats needed
|
|
284
|
+
let isFirst = true;
|
|
285
|
+
while ((precreatedChatSessions.get(key)?.length ?? 0) < targetSize) {
|
|
286
|
+
if (!isFirst) {
|
|
287
|
+
// Reduced delay for faster warm pool filling (upstream: 3806cf6)
|
|
288
|
+
await sleep(300 + Math.floor(Math.random() * 700));
|
|
289
|
+
}
|
|
290
|
+
isFirst = false;
|
|
291
|
+
const chatId = await createQwenChatSession(headers, model, accountId);
|
|
292
|
+
const current = precreatedChatSessions.get(key) ?? [];
|
|
293
|
+
current.push(chatId);
|
|
294
|
+
precreatedChatSessions.set(key, current);
|
|
295
|
+
}
|
|
296
|
+
} catch (err: any) {
|
|
297
|
+
// Mark account as rate-limited if chat creation fails with RateLimited error
|
|
298
|
+
if (err instanceof QwenUpstreamError) {
|
|
299
|
+
if (err.upstreamCode === "RateLimited" || err.upstreamStatus === 429) {
|
|
300
|
+
// Daily quota resets at the next UTC midnight — never the literal
|
|
301
|
+
// "Wait about N hour(s)" hint (near midnight it over-blocks by ~22h).
|
|
302
|
+
markAccountRateLimited(
|
|
303
|
+
accountId || "global",
|
|
304
|
+
computeQuotaCooldownMs(Date.now()),
|
|
305
|
+
"RateLimited",
|
|
306
|
+
);
|
|
307
|
+
console.warn(
|
|
308
|
+
`[WarmPool] Account ${accountId || "global"} rate-limited during chat creation. Marked for cooldown.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (isToolcallDebugEnabled()) {
|
|
313
|
+
logger.debug("[Qwen] Failed to refill chat pool", {
|
|
314
|
+
accountId: accountId || "global",
|
|
315
|
+
model,
|
|
316
|
+
error: err instanceof Error ? err.message : String(err),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
precreatingChatSessions.delete(key);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function scheduleQwenChatPoolRefill(
|
|
325
|
+
headers: Record<string, string>,
|
|
326
|
+
model: string,
|
|
327
|
+
accountId?: string,
|
|
328
|
+
): void {
|
|
329
|
+
setTimeout(() => {
|
|
330
|
+
void refillQwenChatPool(headers, model, accountId);
|
|
331
|
+
}, 250);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function warmQwenChatPool(
|
|
335
|
+
accountId: string | undefined,
|
|
336
|
+
modelId: string,
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
if (!isQwenChatPoolEnabled()) return;
|
|
339
|
+
const { headers } = await getQwenHeaders(false, accountId);
|
|
340
|
+
await refillQwenChatPool(
|
|
341
|
+
headers,
|
|
342
|
+
mapClientModelToQwen(modelId),
|
|
343
|
+
accountId,
|
|
344
|
+
);
|
|
345
|
+
}
|