qwenproxy-cli 1.0.0
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 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
QwenProxyError,
|
|
3
|
+
UpstreamRateLimit,
|
|
4
|
+
UpstreamError,
|
|
5
|
+
AuthError,
|
|
6
|
+
} from "../core/errors.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Messages from the completion fetch that indicate a transient network or
|
|
10
|
+
* first-byte stall. These must be treated as retryable so tryCreateStreamWithRetry
|
|
11
|
+
* rotates to another account instead of failing with a terminal 500 that the
|
|
12
|
+
* client has to retry manually (which looks like an infinite hang).
|
|
13
|
+
*/
|
|
14
|
+
export function isRetryableFetchErrorMessage(message: string): boolean {
|
|
15
|
+
return (
|
|
16
|
+
message.includes("fetch failed") ||
|
|
17
|
+
message.includes("ECONNREFUSED") ||
|
|
18
|
+
message.includes("ETIMEDOUT") ||
|
|
19
|
+
message.includes("etimedout") ||
|
|
20
|
+
message.includes("ENOTFOUND") ||
|
|
21
|
+
message.includes("network") ||
|
|
22
|
+
message.includes("timed out waiting for response headers")
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class RetryableQwenStreamError extends UpstreamRateLimit {
|
|
27
|
+
readonly retryAfterMs: number;
|
|
28
|
+
/** Original upstream category; avoids exposing the inherited rate-limit code. */
|
|
29
|
+
upstreamCode?: string;
|
|
30
|
+
|
|
31
|
+
constructor(message: string, retryAfterMs: number) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "RetryableQwenStreamError";
|
|
34
|
+
this.retryAfterMs = retryAfterMs;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The account-level personalization sync could not be confirmed. Agent
|
|
40
|
+
* instructions ride ONLY the personalization channel (never inline in the
|
|
41
|
+
* prompt), so an unconfirmed sync must fail the attempt — the retry policy
|
|
42
|
+
* rotates to another account — instead of sending a request the model would
|
|
43
|
+
* answer without any instructions or tools. Surfaces as 503 (service
|
|
44
|
+
* degraded) if every account fails to sync.
|
|
45
|
+
*/
|
|
46
|
+
export class PersonalizationSyncError extends QwenProxyError {
|
|
47
|
+
readonly statusCode = 503;
|
|
48
|
+
readonly type = "service_unavailable";
|
|
49
|
+
readonly code = "personalization_unavailable";
|
|
50
|
+
|
|
51
|
+
constructor(message: string) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "PersonalizationSyncError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class QwenUpstreamError extends UpstreamError {
|
|
58
|
+
readonly upstreamCode: string;
|
|
59
|
+
readonly upstreamStatus: number;
|
|
60
|
+
|
|
61
|
+
constructor(message: string, upstreamCode: string, upstreamStatus: number) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "QwenUpstreamError";
|
|
64
|
+
this.upstreamCode = upstreamCode;
|
|
65
|
+
this.upstreamStatus = upstreamStatus;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class QwenSessionExpiredError extends AuthError {
|
|
70
|
+
readonly accountId: string;
|
|
71
|
+
|
|
72
|
+
constructor(message: string, accountId: string) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "QwenSessionExpiredError";
|
|
75
|
+
this.accountId = accountId;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class QwenUpstreamUnavailableError extends RetryableQwenStreamError {
|
|
80
|
+
readonly httpStatusCode: number;
|
|
81
|
+
|
|
82
|
+
constructor(message: string, httpStatusCode: number) {
|
|
83
|
+
super(message, 5000);
|
|
84
|
+
this.name = "QwenUpstreamUnavailableError";
|
|
85
|
+
this.upstreamCode = "upstream_unavailable";
|
|
86
|
+
this.httpStatusCode = httpStatusCode;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class QwenNetworkError extends RetryableQwenStreamError {
|
|
91
|
+
constructor(message: string) {
|
|
92
|
+
super(message, 3000);
|
|
93
|
+
this.name = "QwenNetworkError";
|
|
94
|
+
this.upstreamCode = "network_error";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Return the meaningful upstream category for logs and OpenAI error payloads.
|
|
100
|
+
* RetryableQwenStreamError inherits the rate-limit base class for legacy retry
|
|
101
|
+
* handling, so its inherited `code` must not be used as the actual category.
|
|
102
|
+
*/
|
|
103
|
+
export function getQwenErrorCode(error: unknown): string | undefined {
|
|
104
|
+
if (!error || typeof error !== "object") return undefined;
|
|
105
|
+
|
|
106
|
+
const typed = error as {
|
|
107
|
+
upstreamCode?: unknown;
|
|
108
|
+
code?: unknown;
|
|
109
|
+
message?: unknown;
|
|
110
|
+
};
|
|
111
|
+
if (typeof typed.upstreamCode === "string" && typed.upstreamCode) {
|
|
112
|
+
return typed.upstreamCode;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (error instanceof QwenNetworkError) return "network_error";
|
|
116
|
+
if (error instanceof QwenUpstreamUnavailableError) {
|
|
117
|
+
return "upstream_unavailable";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (error instanceof RetryableQwenStreamError) {
|
|
121
|
+
const message = typeof typed.message === "string" ? typed.message.toLowerCase() : "";
|
|
122
|
+
if (message.includes("chat is in progress")) return "chat_in_progress";
|
|
123
|
+
if (message.includes("not exist") || message.includes("does not exist")) {
|
|
124
|
+
return "chat_not_exist";
|
|
125
|
+
}
|
|
126
|
+
if (message.includes("anti-bot") || message.includes("captcha")) {
|
|
127
|
+
return "waf_challenge";
|
|
128
|
+
}
|
|
129
|
+
return "upstream_retryable";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return typeof typed.code === "string" && typed.code ? typed.code : undefined;
|
|
133
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { qwenUrl, qwenOrigin } from "./qwen-url.ts";
|
|
3
|
+
import { config } from "../core/config.js";
|
|
4
|
+
let dynamicWebVersion: string | null = null;
|
|
5
|
+
|
|
6
|
+
export function updateQwenWebVersion(version?: string | null): void {
|
|
7
|
+
if (version && typeof version === "string" && /^\d+\.\d+\.\d+/.test(version)) {
|
|
8
|
+
dynamicWebVersion = version.trim();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getQwenWebVersion(): string {
|
|
13
|
+
return dynamicWebVersion || config.qwen.webVersion;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const QWEN_WEB_VERSION = config.qwen.webVersion;
|
|
17
|
+
export const DEFAULT_QWEN_USER_AGENT =
|
|
18
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
|
|
19
|
+
const QWEN_TIMEZONE_HEADER = new Date().toString().split(" (")[0];
|
|
20
|
+
|
|
21
|
+
export interface BuildQwenHeadersOptions {
|
|
22
|
+
cookie: string;
|
|
23
|
+
userAgent?: string;
|
|
24
|
+
bxUa?: string;
|
|
25
|
+
bxUmidtoken?: string;
|
|
26
|
+
bxV?: string;
|
|
27
|
+
chatSessionId?: string | null;
|
|
28
|
+
/** Real browser client-hints captured by getBasicHeaders (anti-hardcoded). */
|
|
29
|
+
secChUa?: string;
|
|
30
|
+
secChUaMobile?: string;
|
|
31
|
+
secChUaPlatform?: string;
|
|
32
|
+
/** Real web bundle version captured from the browser request (anti-hardcoded). */
|
|
33
|
+
version?: string;
|
|
34
|
+
extra?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildQwenRequestHeaders(
|
|
38
|
+
opts: BuildQwenHeadersOptions,
|
|
39
|
+
): Record<string, string> {
|
|
40
|
+
const headers: Record<string, string> = {
|
|
41
|
+
...(opts.extra ?? {}),
|
|
42
|
+
Accept: "application/json",
|
|
43
|
+
"Accept-Language": "pt-BR,pt;q=0.9",
|
|
44
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
Cookie: opts.cookie,
|
|
47
|
+
Origin: qwenOrigin(),
|
|
48
|
+
Referer:
|
|
49
|
+
opts.extra?.Referer ??
|
|
50
|
+
(opts.chatSessionId
|
|
51
|
+
? qwenUrl(`/c/${encodeURIComponent(opts.chatSessionId)}`)
|
|
52
|
+
: qwenUrl("/")),
|
|
53
|
+
"Sec-Fetch-Dest": "empty",
|
|
54
|
+
"Sec-Fetch-Mode": "cors",
|
|
55
|
+
"Sec-Fetch-Site": "same-origin",
|
|
56
|
+
Connection: "keep-alive",
|
|
57
|
+
"User-Agent": opts.userAgent || DEFAULT_QWEN_USER_AGENT,
|
|
58
|
+
"X-Request-Id": uuidv4(),
|
|
59
|
+
"bx-v": opts.bxV || "2.5.37",
|
|
60
|
+
source: "web",
|
|
61
|
+
version: opts.version || getQwenWebVersion(),
|
|
62
|
+
timezone: opts.extra?.timezone || new Date().toString().split(" (")[0],
|
|
63
|
+
// Use the real browser client-hints when captured (anti-hardcoded); fall
|
|
64
|
+
// back to the static fingerprint otherwise.
|
|
65
|
+
"sec-ch-ua": opts.secChUa || '"Google Chrome";v="150", "Chromium";v="150", "Not.A/Brand";v="99"',
|
|
66
|
+
"sec-ch-ua-mobile": opts.secChUaMobile || "?0",
|
|
67
|
+
"sec-ch-ua-platform": opts.secChUaPlatform || '"Windows"',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// The real chat.qwen.ai client sends ONLY bx-v on API requests — the WAF
|
|
71
|
+
// carries bx-ua/bx-umidtoken as browser cookies, not headers. Match that
|
|
72
|
+
// unless QWEN_SEND_BX_UA=true restores the legacy injection.
|
|
73
|
+
if (config.qwen.sendBxUa) {
|
|
74
|
+
if (opts.bxUa) headers["bx-ua"] = opts.bxUa;
|
|
75
|
+
if (opts.bxUmidtoken) headers["bx-umidtoken"] = opts.bxUmidtoken;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return headers;
|
|
79
|
+
}
|