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.
Files changed (109) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. 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
+ }