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,3126 @@
1
+ import crypto from "crypto";
2
+ import {
3
+ getQwenHeaders,
4
+ getBasicHeaders,
5
+ isAuthMockEnabled,
6
+ isTokenExpiringSoon,
7
+ } from "./auth-playwright.ts";
8
+ import { v4 as uuidv4 } from "uuid";
9
+ import {
10
+ UpstreamRateLimit,
11
+ ClientAbortedError,
12
+ } from "../core/errors.ts";
13
+ import { buildQwenRequestHeaders } from "./qwen-headers.ts";
14
+ import { qwenOrigin, qwenUrl } from "./qwen-url.ts";
15
+ import { config, type ChatMode } from "../core/config.ts";
16
+ import { logger } from "../core/logger.ts";
17
+ import { estimateTokenCount } from "../utils/context-truncation.ts";
18
+ import type {
19
+ PersonalizationEstimationInfo,
20
+ TokenEstimationContext,
21
+ } from "./token-estimation-metrics.ts";
22
+ import { getDatabase } from "../core/database.ts";
23
+ import { mapClientModelToQwen } from "../core/model-alias.ts";
24
+ import {
25
+ MAX_PAYLOAD_SIZE,
26
+ replaceModelMetadata,
27
+ syncModelMetadata,
28
+ } from "../core/model-registry.ts";
29
+ import { type Page, type BrowserContext } from "patchright";
30
+ import { withAccountPage, assertAntiBotHeaders, onBrowserContextCreated } from "./playwright.ts";
31
+ import { recoverBaxiaCaptcha } from "./captcha-coordinator.ts";
32
+ import { startBaxiaCaptchaWatcher } from "./captcha-solver.ts";
33
+ import { isAccountBusy } from "../core/account-concurrency.ts";
34
+
35
+ // Re-exported from extracted modules for backward compatibility
36
+ export {
37
+ isRetryableFetchErrorMessage,
38
+ RetryableQwenStreamError,
39
+ PersonalizationSyncError,
40
+ QwenUpstreamError,
41
+ QwenSessionExpiredError,
42
+ QwenUpstreamUnavailableError,
43
+ QwenNetworkError,
44
+ getQwenErrorCode,
45
+ } from "./qwen-errors.ts";
46
+ export {
47
+ setToolCapNotice,
48
+ consumeToolCapNotice,
49
+ flushLogicalThreadState,
50
+ getLogicalThreadState,
51
+ updateLogicalThreadState,
52
+ updateLogicalThreadParent,
53
+ updateSessionParent,
54
+ invalidateLogicalThreadParent,
55
+ clearAllSessionsForAccount,
56
+ getSessionParent,
57
+ } from "./qwen-thread-state.ts";
58
+ export type { LogicalThreadEntry } from "./qwen-thread-state.ts";
59
+ export {
60
+ buildChatNewBody,
61
+ isReusableUnusedChatTitle,
62
+ releaseWarmChat,
63
+ acquireNewQwenChatSession,
64
+ warmQwenChatPool,
65
+ } from "./qwen-chat-pool.ts";
66
+
67
+ import {
68
+ isRetryableFetchErrorMessage,
69
+ RetryableQwenStreamError,
70
+ QwenUpstreamError,
71
+ QwenSessionExpiredError,
72
+ QwenUpstreamUnavailableError,
73
+ QwenNetworkError,
74
+ } from "./qwen-errors.ts";
75
+ import {
76
+ clearAllSessionsForAccount,
77
+ getSessionParent,
78
+ updateSessionParent,
79
+ } from "./qwen-thread-state.ts";
80
+ import {
81
+ acquireNewQwenChatSession,
82
+ releaseWarmChat,
83
+ } from "./qwen-chat-pool.ts";
84
+
85
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
86
+
87
+ const BROWSER_STREAM_BINDING = "__qwenProxyStreamEvent";
88
+ const BROWSER_ABORTERS_KEY = "__qwenProxyAborters";
89
+ // Steady-state bridge batching. The first chunk always flushes immediately
90
+ // (see the !firstChunkSent bypass in the in-page reader), so these only govern
91
+ // mid-stream latency: 4KB/25ms held tokens behind a buffer the client writer
92
+ // (8KB/3ms) would have flushed anyway. 512B/8ms matches ~2-3 SSE events per CDP
93
+ // hop — invisible to the WAF (bytes-per-hop is not a signal) and strictly
94
+ // faster for the client.
95
+ const BROWSER_STREAM_FLUSH_BYTES = 512;
96
+ const BROWSER_STREAM_FLUSH_MS = 8;
97
+ const METADATA_TIMEOUT_PER_PAYLOAD_MB_MS = 10_000;
98
+ const POST_CAPTCHA_METADATA_GRACE_MS = 20_000;
99
+
100
+ type BrowserStreamEvent = {
101
+ type: "headers" | "chunk" | "done" | "error";
102
+ status?: number;
103
+ contentType?: string;
104
+ data?: string;
105
+ message?: string;
106
+ errorName?: string;
107
+ };
108
+
109
+ interface BrowserStreamMetadata {
110
+ status: number;
111
+ contentType: string;
112
+ }
113
+
114
+ interface BrowserStreamState {
115
+ chunks: Uint8Array[];
116
+ done: boolean;
117
+ error: Error | null;
118
+ metadata: BrowserStreamMetadata | null;
119
+ waiters: Set<() => void>;
120
+ }
121
+
122
+ const browserStreamStates = new Map<string, BrowserStreamState>();
123
+ const browserStreamBindingPages = new WeakSet<object>();
124
+ const browserStreamBindingContexts = new WeakSet<object>();
125
+
126
+ export async function registerBrowserContextStreamBinding(
127
+ context: BrowserContext,
128
+ ): Promise<void> {
129
+ if (browserStreamBindingContexts.has(context)) return;
130
+ browserStreamBindingContexts.add(context);
131
+ try {
132
+ await context.exposeFunction(
133
+ BROWSER_STREAM_BINDING,
134
+ (requestId: string, event: BrowserStreamEvent) => {
135
+ handleBrowserStreamEvent(requestId, event);
136
+ },
137
+ );
138
+ } catch (error) {
139
+ logger.warn("[Qwen] Failed to register stream binding on context", {
140
+ error: error instanceof Error ? error.message : String(error),
141
+ });
142
+ }
143
+ }
144
+
145
+ onBrowserContextCreated((context) => {
146
+ void registerBrowserContextStreamBinding(context);
147
+ });
148
+
149
+ function wakeBrowserStreamState(state: BrowserStreamState): void {
150
+ const waiters = Array.from(state.waiters);
151
+ state.waiters.clear();
152
+ for (const wake of waiters) wake();
153
+ }
154
+
155
+ function handleBrowserStreamEvent(
156
+ requestId: string,
157
+ event: BrowserStreamEvent,
158
+ ): void {
159
+ const state = browserStreamStates.get(requestId);
160
+ if (!state) return;
161
+
162
+ if (event.type === "headers") {
163
+ state.metadata = {
164
+ status: event.status ?? 0,
165
+ contentType: event.contentType ?? "",
166
+ };
167
+ } else if (event.type === "chunk" && typeof event.data === "string") {
168
+ if (event.data.length > 0) {
169
+ state.chunks.push(Buffer.from(event.data, "utf8"));
170
+ }
171
+ } else if (event.type === "done") {
172
+ state.done = true;
173
+ } else if (event.type === "error") {
174
+ state.error = browserStreamError(
175
+ event.message || "Browser Qwen stream failed",
176
+ event.errorName,
177
+ );
178
+ state.done = true;
179
+ }
180
+
181
+ wakeBrowserStreamState(state);
182
+ }
183
+
184
+ async function ensureBrowserStreamBinding(page: Page): Promise<void> {
185
+ const context = page.context();
186
+ if (!browserStreamBindingContexts.has(context)) {
187
+ await registerBrowserContextStreamBinding(context);
188
+ }
189
+ if (browserStreamBindingPages.has(page)) return;
190
+ try {
191
+ await page.exposeFunction(
192
+ BROWSER_STREAM_BINDING,
193
+ (requestId: string, event: BrowserStreamEvent) => {
194
+ handleBrowserStreamEvent(requestId, event);
195
+ },
196
+ );
197
+ } catch {
198
+ // If context-level binding is already present, page-level expose may throw or no-op.
199
+ }
200
+ browserStreamBindingPages.add(page);
201
+ }
202
+
203
+ function browserStreamError(message: string, errorName?: string): Error {
204
+ const normalizedMessage = message || "Browser Qwen stream failed";
205
+ if (errorName === "AbortError") {
206
+ const abortError = new DOMException(normalizedMessage, "AbortError");
207
+ return abortError;
208
+ }
209
+ return new QwenNetworkError(normalizedMessage);
210
+ }
211
+
212
+ async function waitForBrowserStreamMetadata(
213
+ requestId: string,
214
+ timeoutMs: number,
215
+ ): Promise<BrowserStreamMetadata> {
216
+ const state = browserStreamStates.get(requestId);
217
+ if (!state) {
218
+ throw new Error("Browser Qwen stream state was lost before response headers");
219
+ }
220
+
221
+ const waitForStateChange = new Promise<void>((resolve) => {
222
+ state.waiters.add(resolve);
223
+ if (state.metadata || state.error || state.done) {
224
+ state.waiters.delete(resolve);
225
+ resolve();
226
+ }
227
+ });
228
+
229
+ while (!state.metadata && !state.error && !state.done) {
230
+ await waitForStateChange;
231
+ if (!state.metadata && !state.error && !state.done) {
232
+ return waitForBrowserStreamMetadata(requestId, timeoutMs);
233
+ }
234
+ }
235
+
236
+ if (state.metadata) return state.metadata;
237
+ throw state.error ?? new Error(
238
+ `Browser Qwen stream ended before response headers after ${timeoutMs}ms`,
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Idle timeout for an upstream stream, derived from model type, payload size
244
+ * and whether the stream is an auxiliary parallel-escape request.
245
+ *
246
+ * Base: REASONING_MODEL_TIMEOUT when thinking, IDLE_STREAM_TIMEOUT otherwise;
247
+ * +30s per MB of payload.
248
+ *
249
+ * Auxiliary (parallel-escape) streams serve a small request (e.g. a chat
250
+ * title) that generates in seconds; a SHORT cap frees the account slot fast
251
+ * when the upstream never sends the SSE terminal. The tight cap must ONLY
252
+ * apply to NON-thinking models: thinking streams legitimately pause >15s
253
+ * between reasoning chunks, and a 15s cap killed a 564KB full-replay in
254
+ * production (log 2026-08-21, etimedout idle after 15000ms on qwen3.8-max).
255
+ */
256
+ export function computeDynamicIdleTimeout(opts: {
257
+ enableThinking: boolean;
258
+ parallelEscape?: boolean;
259
+ baseTimeoutMs: number;
260
+ payloadSize: number;
261
+ }): number {
262
+ const payloadMB = opts.payloadSize / (1024 * 1024);
263
+ const dynamic = opts.baseTimeoutMs + Math.ceil(payloadMB * 30_000);
264
+ if (opts.parallelEscape && !opts.enableThinking) {
265
+ return Math.min(15_000, dynamic);
266
+ }
267
+ return dynamic;
268
+ }
269
+
270
+ function addIdleTimeoutToStream(
271
+ stream: ReadableStream<Uint8Array>,
272
+ controller: AbortController,
273
+ idleTimeoutMs: number,
274
+ label: string,
275
+ onTimeout?: () => void,
276
+ onDone?: () => void,
277
+ /**
278
+ * Stricter deadline for the FIRST chunk only (thinking models idle at
279
+ * REASONING_MODEL_TIMEOUT = 600s by default; a stream that produced NOTHING
280
+ * in that window is almost certainly dead, and holding the account slot for
281
+ * 10 minutes stalls the whole session). After the first chunk the normal
282
+ * idleTimeoutMs governs gaps. Aborts here are retryable (etimedout marker).
283
+ */
284
+ firstChunkDeadlineMs?: number,
285
+ ): ReadableStream<Uint8Array> {
286
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
287
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
288
+ let wrapperController: ReadableStreamDefaultController<Uint8Array> | undefined;
289
+ let firstChunkSeen = false;
290
+
291
+ const clearIdleTimer = () => {
292
+ if (idleTimer) {
293
+ clearTimeout(idleTimer);
294
+ idleTimer = undefined;
295
+ }
296
+ };
297
+
298
+ const currentIdleMs = () =>
299
+ firstChunkSeen || firstChunkDeadlineMs === undefined
300
+ ? idleTimeoutMs
301
+ : firstChunkDeadlineMs;
302
+
303
+ const resetIdleTimer = () => {
304
+ clearIdleTimer();
305
+ const timeoutMs = currentIdleMs();
306
+ idleTimer = setTimeout(() => {
307
+ // The `etimedout` marker lets retry-policy (isNetworkLikeError) treat a
308
+ // stalled upstream as a retryable network failure instead of a terminal
309
+ // 500, so the bridge auto-rotates to another account mid-stream.
310
+ const message = `${label} etimedout (${firstChunkSeen ? "idle" : "first-chunk"} timeout after ${timeoutMs}ms without upstream data)`;
311
+ clearIdleTimer();
312
+ controller.abort();
313
+ onTimeout?.();
314
+ // Best-effort cleanup of the upstream source.
315
+ try {
316
+ void stream.cancel(message).catch(() => {});
317
+ } catch {}
318
+ // Error the WRAPPED stream so the bridge's pending read() rejects
319
+ // immediately. Without this, a page/fetch that ignores abort keeps the
320
+ // read pending forever: the stream slot stays held, later requests queue
321
+ // with timeout=unbounded, and no further timeout can ever fire (the
322
+ // timer is one-shot per pull).
323
+ try {
324
+ wrapperController?.error(new Error(message));
325
+ } catch {}
326
+ // Belt-and-braces: settle a pending read on the wrapper's own reader.
327
+ try {
328
+ void reader?.cancel(message).catch(() => {});
329
+ } catch {}
330
+ }, timeoutMs);
331
+ };
332
+
333
+ return new ReadableStream<Uint8Array>({
334
+ start() {
335
+ reader = stream.getReader();
336
+ resetIdleTimer();
337
+ },
338
+ async pull(streamController) {
339
+ wrapperController = streamController;
340
+ try {
341
+ if (!reader) throw new Error("Stream reader was not initialized");
342
+ const { done, value } = await reader.read();
343
+ if (done) {
344
+ clearIdleTimer();
345
+ onDone?.();
346
+ streamController.close();
347
+ return;
348
+ }
349
+ firstChunkSeen = true;
350
+ resetIdleTimer();
351
+ streamController.enqueue(value);
352
+ } catch (error) {
353
+ clearIdleTimer();
354
+ onDone?.();
355
+ streamController.error(error);
356
+ }
357
+ },
358
+ cancel(reason) {
359
+ clearIdleTimer();
360
+ onDone?.();
361
+ return reader ? reader.cancel(reason) : stream.cancel(reason);
362
+ },
363
+ });
364
+ }
365
+
366
+ export interface QwenMessage {
367
+ id: string | null;
368
+ fid: string;
369
+ parentId: string | null;
370
+ childrenIds: string[];
371
+ role: string;
372
+ content: string;
373
+ user_action: string;
374
+ files: any[];
375
+ timestamp: number;
376
+ models: string[];
377
+ model: string;
378
+ chat_type: string;
379
+ feature_config: {
380
+ thinking_enabled: boolean;
381
+ output_schema: string;
382
+ research_mode: string;
383
+ auto_thinking: boolean;
384
+ thinking_mode: string;
385
+ thinking_format?: string;
386
+ auto_search: boolean;
387
+ };
388
+ extra: {
389
+ meta: {
390
+ subChatType: string;
391
+ };
392
+ };
393
+ sub_chat_type: string;
394
+ parent_id: string | null;
395
+ }
396
+
397
+ export interface QwenPayload {
398
+ stream: boolean;
399
+ version: string;
400
+ incremental_output: boolean;
401
+ stream_options?: { include_usage: boolean };
402
+ chatId?: string | null;
403
+ chat_id: string | null;
404
+ parentId?: string;
405
+ chat_mode: string;
406
+ model: string;
407
+ parent_id: string | null;
408
+ messages: QwenMessage[];
409
+ timestamp: number;
410
+ }
411
+
412
+ export interface PublicQwenModel {
413
+ id: string;
414
+ name: string;
415
+ object: "model";
416
+ owned_by: string;
417
+ created: number;
418
+ context_window?: number;
419
+ capabilities?: Record<string, unknown>;
420
+ metadata?: Record<string, unknown>;
421
+ info?: Record<string, unknown>;
422
+ meta?: Record<string, unknown>;
423
+ modality?: string[];
424
+ chat_type?: string[];
425
+ think_skip?: Record<string, unknown>;
426
+ is_active?: boolean;
427
+ [key: string]: unknown;
428
+ }
429
+
430
+ const MODEL_CACHE_TTL_MS = 60 * 60 * 1000;
431
+ const modelsCache = new Map<
432
+ string,
433
+ { models: PublicQwenModel[]; fetchedAt: number }
434
+ >();
435
+
436
+ const nativeToolsDisabled = new Set<string>();
437
+ const disablingNativeToolsInProgress = new Set<string>();
438
+ const lastSyncedPersonalizationHashes = new Map<string, string>();
439
+
440
+ // Direct-fetch circuit breaker for the personalization settings API. A raw
441
+ // Node fetch to chat.qwen.ai can be WAF-blocked (baxia/captcha challenge) in
442
+ // some environments; after N consecutive block-like responses we open the
443
+ // breaker for that account and route personalization through the browser (the
444
+ // guaranteed WAF-safe transport) until the app restarts. This keeps the fast
445
+ // direct path primary while never letting a WAF block cost a round-trip on
446
+ // every sync.
447
+ const directSettingsFetchConsecutiveFailures = new Map<string, number>();
448
+ const DIRECT_SETTINGS_FETCH_BLOCK_THRESHOLD = 2;
449
+ const directSettingsFetchBlocked = new Set<string>();
450
+ const DIRECT_SETTINGS_FETCH_TIMEOUT_MS = 10_000;
451
+
452
+ const activePersonalizationByAccount = new Map<
453
+ string,
454
+ PersonalizationEstimationInfo
455
+ >();
456
+
457
+ function getPersonalizationHashFromDb(accountId: string): string | null {
458
+ try {
459
+ const db = getDatabase();
460
+ const row = db
461
+ .prepare(
462
+ "SELECT instruction_hash FROM personalization_cache WHERE account_id = ?",
463
+ )
464
+ .get(accountId) as { instruction_hash: string } | undefined;
465
+ return row?.instruction_hash ?? null;
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ function setPersonalizationHashInDb(accountId: string, hash: string): void {
472
+ try {
473
+ const db = getDatabase();
474
+ db.prepare(
475
+ `
476
+ INSERT INTO personalization_cache (account_id, instruction_hash, updated_at)
477
+ VALUES (?, ?, datetime('now'))
478
+ ON CONFLICT(account_id) DO UPDATE SET instruction_hash = excluded.instruction_hash, updated_at = excluded.updated_at
479
+ `,
480
+ ).run(accountId, hash);
481
+ } catch (err) {
482
+ console.error(
483
+ `[Qwen] Failed to persist personalization hash for ${accountId}:`,
484
+ (err as Error).message,
485
+ );
486
+ }
487
+ }
488
+
489
+ function shortContentHash(value: string): string {
490
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12);
491
+ }
492
+
493
+ function shortAccountId(accountId: string): string {
494
+ const normalized = accountId.trim();
495
+ return normalized.length > 12 ? normalized.slice(0, 12) : normalized;
496
+ }
497
+
498
+ function textSize(value: unknown): {
499
+ chars: number | null;
500
+ bytes: number | null;
501
+ hash: string | null;
502
+ } {
503
+ if (typeof value !== "string") {
504
+ return { chars: null, bytes: null, hash: null };
505
+ }
506
+ return {
507
+ chars: value.length,
508
+ bytes: Buffer.byteLength(value, "utf8"),
509
+ hash: shortContentHash(value),
510
+ };
511
+ }
512
+
513
+ function rememberActivePersonalization(
514
+ accountId: string,
515
+ instruction: string,
516
+ metadata: {
517
+ model?: string;
518
+ toolsCount?: number;
519
+ },
520
+ source: PersonalizationEstimationInfo["source"],
521
+ ): void {
522
+ const size = textSize(instruction);
523
+ if (size.chars === null || size.bytes === null || !size.hash) return;
524
+
525
+ activePersonalizationByAccount.set(accountId, {
526
+ accountId,
527
+ model: metadata.model ?? null,
528
+ toolCount: metadata.toolsCount ?? 0,
529
+ chars: size.chars,
530
+ bytes: size.bytes,
531
+ hash: size.hash,
532
+ estimatedTokens: estimateTokenCount(instruction),
533
+ source,
534
+ updatedAt: Date.now(),
535
+ });
536
+ }
537
+
538
+ function getActivePersonalizationInfo(
539
+ accountId: string,
540
+ ): PersonalizationEstimationInfo | null {
541
+ return activePersonalizationByAccount.get(accountId) ?? null;
542
+ }
543
+
544
+ export function buildCapturedQwenHeaders(
545
+ headers: Record<string, string>,
546
+ options: {
547
+ chatSessionId?: string | null;
548
+ referer?: string;
549
+ extra?: Record<string, string>;
550
+ } = {},
551
+ ): Record<string, string> {
552
+ assertAntiBotHeaders(headers, "Qwen request");
553
+ return buildQwenRequestHeaders({
554
+ cookie: headers["cookie"],
555
+ userAgent: headers["user-agent"],
556
+ bxUa: headers["bx-ua"],
557
+ bxUmidtoken: headers["bx-umidtoken"],
558
+ bxV: headers["bx-v"],
559
+ secChUa: headers["sec-ch-ua"] || undefined,
560
+ secChUaMobile: headers["sec-ch-ua-mobile"] || undefined,
561
+ secChUaPlatform: headers["sec-ch-ua-platform"] || undefined,
562
+ version: headers["version"] || undefined,
563
+ chatSessionId: options.chatSessionId,
564
+ extra: {
565
+ ...(options.referer ? { Referer: options.referer } : {}),
566
+ ...(options.extra || {}),
567
+ },
568
+ });
569
+ }
570
+
571
+
572
+ // Per-account stream slots: a counting semaphore capped by
573
+ // config.concurrency.maxStreamsPerAccount (NOT a capacity-1 mutex). The browser
574
+ // relay multiplexes concurrent streams on one page via reqId (browserStreamStates),
575
+ // so serializing to 1 here silently overrode the lease cap and made
576
+ // ACCOUNT_MAX_CONCURRENT_STREAMS>1 unreachable (the second stream queued behind
577
+ // the first's whole generation). FIFO handoff on release keeps the old
578
+ // leak-recovery semantics (release is idempotent; a dropped stream still blocks
579
+ // its slot until cancel/idle-timeout frees it — same as before).
580
+ interface AccountStreamSlots {
581
+ active: number;
582
+ queue: Array<() => void>;
583
+ }
584
+ const accountStreamMutexes = new Map<string, AccountStreamSlots>();
585
+
586
+ function getAccountStreamMutex(
587
+ accountId: string,
588
+ ): AccountStreamSlots {
589
+ let slots = accountStreamMutexes.get(accountId);
590
+ if (!slots) {
591
+ slots = { active: 0, queue: [] };
592
+ accountStreamMutexes.set(accountId, slots);
593
+ }
594
+ return slots;
595
+ }
596
+
597
+ function streamSlotCapacity(): number {
598
+ return Math.max(1, config.concurrency.maxStreamsPerAccount);
599
+ }
600
+
601
+ function createStreamSlotRelease(slots: AccountStreamSlots): () => void {
602
+ let released = false;
603
+ return () => {
604
+ if (released) return;
605
+ released = true;
606
+ slots.active -= 1;
607
+ const next = slots.queue.shift();
608
+ if (next) {
609
+ // Hand the slot directly to the waiter (FIFO), same as the old mutex.
610
+ slots.active += 1;
611
+ next();
612
+ }
613
+ };
614
+ }
615
+
616
+ async function acquireAccountStreamLock(accountId: string): Promise<() => void> {
617
+ const slots = getAccountStreamMutex(accountId);
618
+ if (slots.active < streamSlotCapacity()) {
619
+ slots.active += 1;
620
+ return Promise.resolve(createStreamSlotRelease(slots));
621
+ }
622
+ return new Promise<() => void>((resolve) => {
623
+ slots.queue.push(() => resolve(createStreamSlotRelease(slots)));
624
+ });
625
+ }
626
+
627
+ const QWEN_SAFE_SETTINGS_PATCH = {
628
+ ui: {
629
+ autoTags: false,
630
+ largeTextAsFile: false,
631
+ splitLargeChunks: false,
632
+ },
633
+ mcp_remind: false,
634
+ memory: {
635
+ enable_memory: false,
636
+ enable_history_memory: false,
637
+ memory_version_reminder: false,
638
+ },
639
+ tools_enabled: {
640
+ web_extractor: false,
641
+ web_search_image: false,
642
+ web_search: false,
643
+ image_gen_tool: false,
644
+ code_interpreter: false,
645
+ history_retriever: false,
646
+ image_edit_tool: false,
647
+ bio: false,
648
+ image_zoom_in_tool: false,
649
+ },
650
+ } as const;
651
+
652
+ const QWEN_SAFE_SETTINGS_HASH = crypto
653
+ .createHash("sha256")
654
+ .update(JSON.stringify(QWEN_SAFE_SETTINGS_PATCH))
655
+ .digest("hex")
656
+ .slice(0, 12);
657
+
658
+ export function buildQwenSettingsUpdatePayload(
659
+ currentSettings: any,
660
+ instruction: string,
661
+ ): Record<string, unknown> {
662
+ // The real client (HAR networkv2) POSTs ONLY `{personalization: {...}}` to
663
+ // /api/v2/users/user/settings/update. Live probes confirmed the personalization
664
+ // object accepts the GET-personalization spread + enable_for_new_chat, but the
665
+ // FULL-settings spread this used to send (ui/memory/tools_enabled + every GET
666
+ // field like tts_speaker_v2, code_settings, manage_cookies) is rejected with
667
+ // RequestValidationError. Safe-settings are applied by disableNativeTools as
668
+ // their own combined partial POST (probe-accepted). NOTE: the persistent
669
+ // RequestValidationError that haunted the sync was NOT the payload — it was a
670
+ // missing Content-Type header (attemptPost received the raw getQwenHeaders
671
+ // map); the body was not parsed as a JSON object ("Field '': Input should be
672
+ // a valid dictionary...").
673
+ const currentPersonalization =
674
+ currentSettings?.personalization &&
675
+ typeof currentSettings.personalization === "object"
676
+ ? currentSettings.personalization
677
+ : {};
678
+
679
+ return {
680
+ personalization: {
681
+ ...currentPersonalization,
682
+ name: "",
683
+ description:
684
+ currentPersonalization.description === undefined
685
+ ? null
686
+ : currentPersonalization.description,
687
+ style: null,
688
+ instruction,
689
+ enable_for_new_chat: true,
690
+ },
691
+ };
692
+ }
693
+
694
+ export async function readJsonTextResponse(
695
+ response: Response,
696
+ options: { strict?: boolean } = {},
697
+ ): Promise<{ raw: string; json: any }> {
698
+ const raw = await response.text();
699
+ if (!raw) {
700
+ return { raw, json: null };
701
+ }
702
+
703
+ // Pre-check HTTP status: upstream gateways (like Alibaba GA) returning 502/503/504
704
+ // with an HTML body (e.g. <html><head><title>502 Bad Gateway</title>...) will fail JSON.parse.
705
+ // In strict mode on a non-ok response with non-JSON/HTML, throw an upfront descriptive upstream error
706
+ // rather than a cryptic SyntaxError ("Unexpected token '<'").
707
+ if (!response.ok && (raw.trimStart().startsWith("<") || response.status >= 500)) {
708
+ if (options.strict) {
709
+ const { QwenUpstreamError } = await import("./qwen-errors.ts");
710
+ throw new QwenUpstreamError(
711
+ `Upstream gateway error ${response.status} ${response.statusText}: ${raw.substring(0, 200)}`,
712
+ "UpstreamGatewayError",
713
+ response.status >= 500 ? 502 : response.status,
714
+ );
715
+ }
716
+ return { raw, json: null };
717
+ }
718
+
719
+ try {
720
+ return { raw, json: JSON.parse(raw) };
721
+ } catch (error) {
722
+ if (options.strict) {
723
+ throw error;
724
+ }
725
+ return { raw, json: null };
726
+ }
727
+ }
728
+
729
+ async function withQwenBrowserPage<T>(
730
+ accountId: string,
731
+ fn: (page: Page) => Promise<T>,
732
+ targetPath?: string,
733
+ operationTimeoutMs = config.timeouts.page,
734
+ recoverOnTimeout = true,
735
+ ): Promise<T> {
736
+ // Keep the account page on the chat UI for normal browser operations. The
737
+ // personalization helper passes /settings/personalization explicitly; an
738
+ // omitted target must not leave a same-origin settings page in place.
739
+ const effectiveTargetPath = targetPath || "/";
740
+ const targetUrl = qwenUrl(effectiveTargetPath);
741
+ const targetOrigin = new URL(targetUrl).origin;
742
+ const normalizedTargetPath =
743
+ new URL(targetUrl).pathname.replace(/\/+$/, "") || "/";
744
+
745
+ return withAccountPage(
746
+ accountId,
747
+ async (page) => {
748
+ let currentOrigin = "";
749
+ let currentPath = "";
750
+ try {
751
+ const currentUrl = new URL(page.url());
752
+ currentOrigin = currentUrl.origin;
753
+ currentPath = currentUrl.pathname.replace(/\/+$/, "") || "/";
754
+ } catch {
755
+ // Navigate below when the current page has no usable URL.
756
+ }
757
+
758
+ if (
759
+ currentOrigin !== targetOrigin ||
760
+ (normalizedTargetPath && currentPath !== normalizedTargetPath)
761
+ ) {
762
+ await page.goto(targetUrl, {
763
+ waitUntil: "domcontentloaded",
764
+ timeout: Math.min(config.timeouts.navigation, operationTimeoutMs),
765
+ });
766
+ }
767
+
768
+ return fn(page);
769
+ },
770
+ operationTimeoutMs,
771
+ Math.min(config.timeouts.page, 5_000),
772
+ recoverOnTimeout,
773
+ );
774
+ }
775
+
776
+ async function withQwenPersonalizationPage<T>(
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
+ }
814
+
815
+ /**
816
+ * Build minimal headers for browser-side fetch. The browser automatically
817
+ * adds Cookie, User-Agent, Origin, Referer, and sec-* headers, so we only
818
+ * pass the anti-bot tokens and metadata that the browser cannot infer.
819
+ */
820
+ function getBrowserFetchHeaders(
821
+ headers: Record<string, string>,
822
+ ): Record<string, string> {
823
+ const browserAllowedHeaders = new Set([
824
+ "accept",
825
+ "content-type",
826
+ "bx-ua",
827
+ "bx-umidtoken",
828
+ "bx-v",
829
+ "source",
830
+ "version",
831
+ "timezone",
832
+ "x-request-id",
833
+ "x-accel-buffering",
834
+ ]);
835
+
836
+ return Object.fromEntries(
837
+ Object.entries(headers).filter(([name]) =>
838
+ browserAllowedHeaders.has(name.toLowerCase()),
839
+ ),
840
+ );
841
+ }
842
+
843
+ interface BrowserTextResponse {
844
+ status: number;
845
+ contentType: string;
846
+ raw: string;
847
+ }
848
+
849
+ export async function requestQwenTextInBrowser(
850
+ accountId: string | undefined,
851
+ method: "GET" | "POST" | "DELETE",
852
+ path: string,
853
+ headers: Record<string, string>,
854
+ body?: string,
855
+ options: {
856
+ settingsPage?: boolean;
857
+ referrer?: string;
858
+ timeoutMs?: number;
859
+ /**
860
+ * Best-effort operations (e.g. the post-disconnect stop) must not trigger
861
+ * the aggressive stuck-mutex recovery: the mutex is legitimately held by the
862
+ * NEW request that superseded this one, and closing the context / resetting
863
+ * the profile would kill the account for a healthy in-flight generation.
864
+ */
865
+ noMutexRecovery?: boolean;
866
+ } = {},
867
+ ): Promise<Response> {
868
+ const url = qwenUrl(path);
869
+
870
+ // Mock tests intentionally use Node fetch and do not initialize a browser.
871
+ if (isAuthMockEnabled()) {
872
+ return fetch(url, {
873
+ method,
874
+ headers,
875
+ ...(body === undefined ? {} : { body }),
876
+ });
877
+ }
878
+
879
+ if (!accountId) {
880
+ throw new Error("A Qwen account is required for browser request");
881
+ }
882
+
883
+ const browserHeaders = getBrowserFetchHeaders(headers);
884
+ if (
885
+ body !== undefined &&
886
+ !Object.keys(browserHeaders).some(
887
+ (name) => name.toLowerCase() === "content-type",
888
+ )
889
+ ) {
890
+ browserHeaders["Content-Type"] = "application/json";
891
+ }
892
+
893
+ const evaluateRequest = (page: Page) =>
894
+ page.evaluate(
895
+ async ({ url, method, headers, body, referrer }: {
896
+ url: string;
897
+ method: "GET" | "POST" | "DELETE";
898
+ headers: Record<string, string>;
899
+ body?: string;
900
+ referrer?: string;
901
+ }): Promise<BrowserTextResponse> => {
902
+ const response = await fetch(url, {
903
+ method,
904
+ credentials: "include",
905
+ headers,
906
+ body,
907
+ ...(referrer ? { referrer } : {}),
908
+ });
909
+ return {
910
+ status: response.status,
911
+ contentType: response.headers.get("content-type") || "",
912
+ raw: await response.text(),
913
+ };
914
+ },
915
+ {
916
+ url,
917
+ method,
918
+ headers: browserHeaders,
919
+ body,
920
+ referrer: options.referrer,
921
+ },
922
+ );
923
+ const recoverOnTimeout = !options.noMutexRecovery;
924
+ // Settings and personalization requests run as same-origin in-browser fetch
925
+ // with appropriate Referer, keeping the page on the stable chat UI without
926
+ // expensive page.goto navigations that can time out under load.
927
+ const response = await withQwenBrowserPage<BrowserTextResponse>(
928
+ accountId,
929
+ evaluateRequest,
930
+ undefined,
931
+ options.timeoutMs,
932
+ recoverOnTimeout,
933
+ );
934
+
935
+ return new Response(response.raw, {
936
+ status: response.status,
937
+ headers: response.contentType
938
+ ? { "content-type": response.contentType }
939
+ : undefined,
940
+ });
941
+ }
942
+
943
+ /**
944
+ * Direct Node fetch of Qwen settings/user APIs using the captured (now
945
+ * anti-hardcoded) headers. This is exactly what the real web client does — a
946
+ * plain fetch against `/api/v2/users/user/settings` on the same origin — and
947
+ * it avoids the flaky `page.evaluate` + settings-page navigation that hung
948
+ * the personalization sync (the 30s sync timeout / "stuck page operation").
949
+ *
950
+ * Returns null (instead of throwing) when the direct path should not be used:
951
+ * the account's circuit breaker is open, a WAF block is detected, or the
952
+ * request errors — in all cases the caller falls back to the browser path.
953
+ */
954
+ export async function requestQwenSettingsDirectFetch(
955
+ accountId: string | undefined,
956
+ method: "GET" | "POST",
957
+ path: string,
958
+ headers: Record<string, string>,
959
+ payload?: Record<string, unknown>,
960
+ ): Promise<{ status: number; raw: string; json: any } | null> {
961
+ const cacheKey = accountId || "global";
962
+ if (directSettingsFetchBlocked.has(cacheKey)) {
963
+ return null;
964
+ }
965
+
966
+ const url = qwenUrl(path);
967
+ const controller = new AbortController();
968
+ const timeoutId = setTimeout(
969
+ () => controller.abort(),
970
+ DIRECT_SETTINGS_FETCH_TIMEOUT_MS,
971
+ );
972
+ try {
973
+ const response = await fetch(url, {
974
+ method,
975
+ headers,
976
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
977
+ signal: controller.signal,
978
+ });
979
+ const raw = await response.text();
980
+ const contentType =
981
+ response.headers.get("content-type") || "application/json";
982
+
983
+ // A WAF/baxia challenge (or proxy error page) is HTML, not the JSON the
984
+ // settings API always returns. If we see one, treat it as a block and let
985
+ // the browser path take over — the browser has the real fingerprint that
986
+ // passes the WAF.
987
+ let json: any = null;
988
+ let okShape = false;
989
+ if (contentType.includes("html")) {
990
+ okShape = false;
991
+ } else {
992
+ try {
993
+ json = JSON.parse(raw);
994
+ okShape = json && typeof json === "object" && "success" in json;
995
+ } catch {
996
+ okShape = false;
997
+ }
998
+ }
999
+
1000
+ if (!okShape) {
1001
+ const failures = (directSettingsFetchConsecutiveFailures.get(cacheKey) ??
1002
+ 0) + 1;
1003
+ if (failures >= DIRECT_SETTINGS_FETCH_BLOCK_THRESHOLD) {
1004
+ directSettingsFetchBlocked.add(cacheKey);
1005
+ logger.debug(
1006
+ "[Qwen] Direct settings fetch WAF-blocked; routing personalization through the browser",
1007
+ {
1008
+ accountId: cacheKey,
1009
+ path,
1010
+ consecutiveFailures: failures,
1011
+ contentType,
1012
+ status: response.status,
1013
+ },
1014
+ );
1015
+ } else {
1016
+ directSettingsFetchConsecutiveFailures.set(cacheKey, failures);
1017
+ }
1018
+ return null;
1019
+ }
1020
+
1021
+ directSettingsFetchConsecutiveFailures.delete(cacheKey);
1022
+ logger.debug("[Qwen] Direct settings fetch succeeded", {
1023
+ accountId: cacheKey,
1024
+ path,
1025
+ status: response.status,
1026
+ });
1027
+ return { status: response.status, raw, json };
1028
+ } catch (err) {
1029
+ // Network error or the bounded timeout — fall back to the browser path.
1030
+ logger.debug("[Qwen] Direct settings fetch failed; using browser path", {
1031
+ accountId: cacheKey,
1032
+ path,
1033
+ error: err instanceof Error ? err.message : String(err),
1034
+ });
1035
+ return null;
1036
+ } finally {
1037
+ clearTimeout(timeoutId);
1038
+ }
1039
+ }
1040
+
1041
+ async function requestQwenPersonalizationInBrowser(
1042
+ accountId: string | undefined,
1043
+ method: "GET" | "POST",
1044
+ path: string,
1045
+ headers: Record<string, string>,
1046
+ payload?: Record<string, unknown>,
1047
+ ): Promise<{ status: number; raw: string; json: any }> {
1048
+ // If browser-only fetch is disabled, try direct Node fetch as fast-path
1049
+ if (!config.qwen.browserOnlyFetch && !isAuthMockEnabled()) {
1050
+ const direct = await requestQwenSettingsDirectFetch(
1051
+ accountId,
1052
+ method,
1053
+ path,
1054
+ headers,
1055
+ payload,
1056
+ );
1057
+ if (direct) {
1058
+ return direct;
1059
+ }
1060
+ }
1061
+
1062
+ const response = await requestQwenTextInBrowser(
1063
+ accountId,
1064
+ method,
1065
+ path,
1066
+ headers,
1067
+ payload === undefined ? undefined : JSON.stringify(payload),
1068
+ {
1069
+ settingsPage: true,
1070
+ referrer: qwenUrl("/settings/personalization"),
1071
+ },
1072
+ );
1073
+ const { raw, json } = await readJsonTextResponse(response);
1074
+ return { status: response.status, raw, json };
1075
+ }
1076
+
1077
+ async function cancelQwenBrowserStream(
1078
+ accountId: string,
1079
+ requestId: string,
1080
+ ): Promise<void> {
1081
+ const state = browserStreamStates.get(requestId);
1082
+ if (state) {
1083
+ state.done = true;
1084
+ wakeBrowserStreamState(state);
1085
+ }
1086
+
1087
+ try {
1088
+ await withQwenBrowserPage(accountId, async (page) => {
1089
+ await page.evaluate(
1090
+ ({ abortersKey, requestId }: {
1091
+ abortersKey: string;
1092
+ requestId: string;
1093
+ }) => {
1094
+ const aborters = (globalThis as unknown as Record<string, unknown>)[
1095
+ abortersKey
1096
+ ] as Map<string, AbortController> | undefined;
1097
+ aborters?.get(requestId)?.abort();
1098
+ },
1099
+ { abortersKey: BROWSER_ABORTERS_KEY, requestId },
1100
+ );
1101
+ });
1102
+ } catch {
1103
+ // The page may already be closing after an abort or timeout.
1104
+ } finally {
1105
+ browserStreamStates.delete(requestId);
1106
+ }
1107
+ }
1108
+
1109
+ async function createQwenBrowserResponse(
1110
+ accountId: string | undefined,
1111
+ url: string,
1112
+ method: "POST",
1113
+ headers: Record<string, string>,
1114
+ body: string,
1115
+ signal: AbortSignal,
1116
+ referrer?: string,
1117
+ pageOperationTimeoutMs = config.timeouts.page,
1118
+ ): Promise<Response> {
1119
+ if (isAuthMockEnabled()) {
1120
+ return fetch(url, {
1121
+ method,
1122
+ headers,
1123
+ body,
1124
+ signal,
1125
+ });
1126
+ }
1127
+
1128
+ if (!accountId) {
1129
+ throw new Error("A Qwen account is required for browser streaming");
1130
+ }
1131
+ if (signal.aborted) {
1132
+ throw new DOMException("The operation was aborted", "AbortError");
1133
+ }
1134
+
1135
+ const requestId = uuidv4();
1136
+ const state: BrowserStreamState = {
1137
+ chunks: [],
1138
+ done: false,
1139
+ error: null,
1140
+ metadata: null,
1141
+ waiters: new Set(),
1142
+ };
1143
+ browserStreamStates.set(requestId, state);
1144
+
1145
+ const browserHeaders = getBrowserFetchHeaders(headers);
1146
+ if (
1147
+ !Object.keys(browserHeaders).some(
1148
+ (name) => name.toLowerCase() === "content-type",
1149
+ )
1150
+ ) {
1151
+ browserHeaders["Content-Type"] = "application/json";
1152
+ }
1153
+
1154
+ let settled = false;
1155
+ let abortListener: (() => void) | undefined;
1156
+ let cancelPromise: Promise<void> | undefined;
1157
+ const cleanup = () => {
1158
+ if (settled) return;
1159
+ settled = true;
1160
+ if (abortListener) signal.removeEventListener("abort", abortListener);
1161
+ browserStreamStates.delete(requestId);
1162
+ };
1163
+ const cancel = () => {
1164
+ if (!cancelPromise) {
1165
+ cancelPromise = cancelQwenBrowserStream(accountId, requestId);
1166
+ }
1167
+ cleanup();
1168
+ return cancelPromise;
1169
+ };
1170
+
1171
+ abortListener = () => {
1172
+ void cancel();
1173
+ };
1174
+ signal.addEventListener("abort", abortListener, { once: true });
1175
+
1176
+ const payloadMbForMetadata = Math.ceil(
1177
+ Buffer.byteLength(body, "utf8") / (1024 * 1024),
1178
+ );
1179
+ // First-byte deadline for the completion fetch: honor TIME_TO_FIRST_BYTE
1180
+ // (default 60s) with a 15s floor. A stall past this window is a dead
1181
+ // connection / WAF swallow — classified retryable by the caller.
1182
+ const metadataTimeoutMs = Math.max(
1183
+ 5_000,
1184
+ Math.min(
1185
+ pageOperationTimeoutMs,
1186
+ Math.max(15_000, config.timeouts.timeToFirstByte) +
1187
+ payloadMbForMetadata * METADATA_TIMEOUT_PER_PAYLOAD_MB_MS,
1188
+ ),
1189
+ );
1190
+ let captchaWatcher: ReturnType<typeof startBaxiaCaptchaWatcher> | undefined;
1191
+
1192
+ try {
1193
+ const startOperationTimeoutMs = Math.max(
1194
+ 5_000,
1195
+ Math.min(config.timeouts.navigation, pageOperationTimeoutMs),
1196
+ );
1197
+ const started = await withQwenBrowserPage(
1198
+ accountId,
1199
+ async (page) => {
1200
+ await ensureBrowserStreamBinding(page);
1201
+ if (config.captcha.enabled) {
1202
+ captchaWatcher = startBaxiaCaptchaWatcher(
1203
+ page,
1204
+ metadataTimeoutMs,
1205
+ {
1206
+ maxAttempts: config.captcha.maxAttempts,
1207
+ retryDelayMs: config.captcha.retryDelayMs,
1208
+ settleMs: config.captcha.settleMs,
1209
+ },
1210
+ );
1211
+ }
1212
+ return page.evaluate(
1213
+ ({
1214
+ url,
1215
+ method,
1216
+ headers,
1217
+ body,
1218
+ referrer,
1219
+ requestId,
1220
+ bindingName,
1221
+ abortersKey,
1222
+ flushBytes,
1223
+ flushMs,
1224
+ timeoutMs,
1225
+ }: {
1226
+ url: string;
1227
+ method: "POST";
1228
+ headers: Record<string, string>;
1229
+ body: string;
1230
+ referrer?: string;
1231
+ requestId: string;
1232
+ bindingName: string;
1233
+ abortersKey: string;
1234
+ flushBytes: number;
1235
+ flushMs: number;
1236
+ timeoutMs: number;
1237
+ }) => {
1238
+ const globalObject = globalThis as unknown as Record<string, unknown>;
1239
+ const notify = globalObject[bindingName] as (
1240
+ (id: string, event: BrowserStreamEvent) => Promise<void>
1241
+ );
1242
+ if (typeof notify !== "function") {
1243
+ throw new Error("Qwen browser stream binding is unavailable");
1244
+ }
1245
+
1246
+ let aborters = globalObject[abortersKey] as
1247
+ | Map<string, AbortController>
1248
+ | undefined;
1249
+ if (!aborters) {
1250
+ aborters = new Map<string, AbortController>();
1251
+ globalObject[abortersKey] = aborters;
1252
+ }
1253
+ const abortController = new AbortController();
1254
+ aborters.set(requestId, abortController);
1255
+ const timeoutId = setTimeout(
1256
+ () => abortController.abort(),
1257
+ timeoutMs,
1258
+ );
1259
+
1260
+ void (async () => {
1261
+ try {
1262
+ const response = await fetch(url, {
1263
+ method,
1264
+ credentials: "include",
1265
+ headers,
1266
+ body,
1267
+ signal: abortController.signal,
1268
+ ...(referrer ? { referrer } : {}),
1269
+ });
1270
+ clearTimeout(timeoutId);
1271
+
1272
+ await notify(requestId, {
1273
+ type: "headers",
1274
+ status: response.status,
1275
+ contentType: response.headers.get("content-type") || "",
1276
+ });
1277
+
1278
+ if (!response.body) {
1279
+ await notify(requestId, { type: "done" });
1280
+ return;
1281
+ }
1282
+
1283
+ const reader = response.body.getReader();
1284
+ const decoder = new TextDecoder();
1285
+ let buffered = "";
1286
+ let lastFlushAt = Date.now();
1287
+ let firstChunkSent = false;
1288
+
1289
+ while (true) {
1290
+ const { done, value } = await reader.read();
1291
+ if (done) break;
1292
+ if (!value) continue;
1293
+
1294
+ buffered += decoder.decode(value, { stream: true });
1295
+ if (
1296
+ !firstChunkSent ||
1297
+ buffered.length >= flushBytes ||
1298
+ Date.now() - lastFlushAt >= flushMs
1299
+ ) {
1300
+ const data = buffered;
1301
+ buffered = "";
1302
+ firstChunkSent = true;
1303
+ lastFlushAt = Date.now();
1304
+ await notify(requestId, { type: "chunk", data });
1305
+ }
1306
+ }
1307
+
1308
+ buffered += decoder.decode();
1309
+ if (buffered) {
1310
+ const data = buffered;
1311
+ buffered = "";
1312
+ await notify(requestId, { type: "chunk", data });
1313
+ }
1314
+ await notify(requestId, { type: "done" });
1315
+ } catch (error) {
1316
+ clearTimeout(timeoutId);
1317
+ try {
1318
+ await notify(requestId, {
1319
+ type: "error",
1320
+ message:
1321
+ error instanceof Error ? error.message : String(error),
1322
+ errorName: error instanceof Error ? error.name : undefined,
1323
+ });
1324
+ } catch {
1325
+ // Node may have cancelled the stream already.
1326
+ }
1327
+ } finally {
1328
+ aborters?.delete(requestId);
1329
+ }
1330
+ })();
1331
+
1332
+ // Do not await the upstream fetch here. Returning immediately
1333
+ // releases the per-account Playwright mutex while metadata/chunks
1334
+ // continue through the exposed binding.
1335
+ return true;
1336
+ },
1337
+ {
1338
+ url,
1339
+ method,
1340
+ headers: browserHeaders,
1341
+ body,
1342
+ referrer,
1343
+ requestId,
1344
+ bindingName: BROWSER_STREAM_BINDING,
1345
+ abortersKey: BROWSER_ABORTERS_KEY,
1346
+ flushBytes: BROWSER_STREAM_FLUSH_BYTES,
1347
+ flushMs: BROWSER_STREAM_FLUSH_MS,
1348
+ timeoutMs: metadataTimeoutMs,
1349
+ },
1350
+ );
1351
+ },
1352
+ undefined,
1353
+ startOperationTimeoutMs,
1354
+ );
1355
+
1356
+ if (!started) {
1357
+ throw new Error("Qwen browser stream failed to start");
1358
+ }
1359
+
1360
+ let captchaSolvedDuringMetadata = false;
1361
+ let metadataTimer: ReturnType<typeof setTimeout> | undefined;
1362
+ const metadataTimeoutPromise = new Promise<never>((_, reject) => {
1363
+ const fail = () =>
1364
+ reject(
1365
+ new Error(
1366
+ `Qwen browser stream timed out waiting for response headers after ${metadataTimeoutMs}ms${
1367
+ captchaSolvedDuringMetadata
1368
+ ? " (captcha solved; original request did not resume)"
1369
+ : ""
1370
+ }`,
1371
+ ),
1372
+ );
1373
+ metadataTimer = setTimeout(fail, metadataTimeoutMs);
1374
+ metadataTimer.unref?.();
1375
+
1376
+ // When the watcher solves a challenge while headers have not arrived, the
1377
+ // original background fetch often remains stalled. Give it a short grace
1378
+ // window; if nothing arrives, the caller retries with fresh headers.
1379
+ if (captchaWatcher) {
1380
+ void captchaWatcher.promise
1381
+ .then((solved) => {
1382
+ if (!solved) return;
1383
+ captchaSolvedDuringMetadata = true;
1384
+ if (metadataTimer) {
1385
+ clearTimeout(metadataTimer);
1386
+ metadataTimer = setTimeout(fail, POST_CAPTCHA_METADATA_GRACE_MS);
1387
+ metadataTimer.unref?.();
1388
+ }
1389
+ })
1390
+ .catch(() => undefined);
1391
+ }
1392
+ });
1393
+
1394
+ let metadata: BrowserStreamMetadata;
1395
+ try {
1396
+ metadata = await Promise.race([
1397
+ waitForBrowserStreamMetadata(
1398
+ requestId,
1399
+ metadataTimeoutMs + POST_CAPTCHA_METADATA_GRACE_MS,
1400
+ ),
1401
+ metadataTimeoutPromise,
1402
+ ]);
1403
+ } catch (error) {
1404
+ if (
1405
+ captchaSolvedDuringMetadata &&
1406
+ error instanceof Error &&
1407
+ error.message.includes("timed out waiting for response headers")
1408
+ ) {
1409
+ const retryableError = new Error(error.message);
1410
+ (retryableError as any).captchaSolvedDuringMetadata = true;
1411
+ throw retryableError;
1412
+ }
1413
+ throw error;
1414
+ } finally {
1415
+ if (metadataTimer) clearTimeout(metadataTimer);
1416
+ }
1417
+
1418
+ if (signal.aborted) {
1419
+ throw new DOMException("The operation was aborted", "AbortError");
1420
+ }
1421
+
1422
+ const stream = new ReadableStream<Uint8Array>({
1423
+ async pull(controller) {
1424
+ const current = browserStreamStates.get(requestId);
1425
+ if (!current) {
1426
+ controller.close();
1427
+ return;
1428
+ }
1429
+
1430
+ while (current.chunks.length === 0 && !current.done) {
1431
+ await new Promise<void>((resolve) => {
1432
+ current.waiters.add(resolve);
1433
+ if (current.chunks.length > 0 || current.done) {
1434
+ current.waiters.delete(resolve);
1435
+ resolve();
1436
+ }
1437
+ });
1438
+ }
1439
+
1440
+ if (current.chunks.length > 0) {
1441
+ controller.enqueue(current.chunks.shift()!);
1442
+ return;
1443
+ }
1444
+
1445
+ cleanup();
1446
+ if (current.error) {
1447
+ controller.error(current.error);
1448
+ } else {
1449
+ controller.close();
1450
+ }
1451
+ },
1452
+ cancel() {
1453
+ return cancel();
1454
+ },
1455
+ });
1456
+
1457
+ return new Response(stream, {
1458
+ status: metadata.status,
1459
+ headers: metadata.contentType
1460
+ ? { "content-type": metadata.contentType }
1461
+ : undefined,
1462
+ });
1463
+ } catch (error) {
1464
+ captchaWatcher?.stop();
1465
+ await cancel().catch(() => {});
1466
+ throw error;
1467
+ } finally {
1468
+ captchaWatcher?.stop();
1469
+ }
1470
+ }
1471
+
1472
+ export async function syncQwenRequestPersonalization(
1473
+ instruction: string,
1474
+ accountId?: string,
1475
+ metadata: {
1476
+ model?: string;
1477
+ toolsCount?: number;
1478
+ sessionId?: string | null;
1479
+ promptChars?: number;
1480
+ /** Bypass memory/DB/GET caches and always POST. Used on new chat creation. */
1481
+ forceSync?: boolean;
1482
+ } = {},
1483
+ ): Promise<boolean> {
1484
+ if (isAuthMockEnabled()) {
1485
+ // Test hook: force the sync to report "not applied" so the fail-fast
1486
+ // contract (personalization-required suite) is exercisable in mock mode.
1487
+ if (process.env.TEST_PERSONALIZATION_SYNC_FAIL === "true") return false;
1488
+ return true;
1489
+ }
1490
+ // instruction pode ser vazia para limpar personalization
1491
+
1492
+ const cacheKey = accountId || "global";
1493
+
1494
+ // Proactive token renewal: refresh BEFORE attempting personalization
1495
+ // to avoid 401 errors that waste time on retry
1496
+ let forceRefresh = false;
1497
+ try {
1498
+ const basic = await getBasicHeaders(accountId);
1499
+ if (isTokenExpiringSoon(basic.cookie, 5)) {
1500
+ logger.debug("[Qwen] Token expiring soon, refreshing proactively", {
1501
+ accountId: cacheKey,
1502
+ });
1503
+ forceRefresh = true;
1504
+ }
1505
+ } catch {
1506
+ // If we can't check, let the normal flow handle it
1507
+ }
1508
+
1509
+ const { headers } = await getQwenHeaders(forceRefresh, accountId);
1510
+ let requestHeaders = buildCapturedQwenHeaders(headers, {
1511
+ referer: qwenUrl("/settings/personalization"),
1512
+ });
1513
+ let currentSettings: any = null;
1514
+ let payload = buildQwenSettingsUpdatePayload(currentSettings, instruction);
1515
+
1516
+ const sent = textSize(instruction);
1517
+ const syncHash = sent.hash ? `${sent.hash}:${QWEN_SAFE_SETTINGS_HASH}` : null;
1518
+ const bypassCache = metadata.forceSync === true;
1519
+
1520
+ // 1. Check memory cache (skipped on forceSync)
1521
+ const cachedHash = lastSyncedPersonalizationHashes.get(cacheKey);
1522
+ if (!bypassCache && syncHash && cachedHash === syncHash) {
1523
+ rememberActivePersonalization(cacheKey, instruction, metadata, "memory");
1524
+ // Personalization unchanged - no log needed
1525
+ return true;
1526
+ }
1527
+
1528
+ // Diagnostic: log when the in-memory hash exists but differs, so we can
1529
+ // trace what is destabilising the personalization hash between requests.
1530
+ if (!bypassCache && syncHash && cachedHash && cachedHash !== syncHash) {
1531
+ logger.debug("[Qwen] personalization cache miss (hash changed)", {
1532
+ accountId: cacheKey,
1533
+ cachedHash,
1534
+ newHash: syncHash,
1535
+ model: metadata.model || null,
1536
+ tools: metadata.toolsCount ?? 0,
1537
+ });
1538
+ }
1539
+
1540
+ // 2. Check DB cache (survives restarts) (skipped on forceSync)
1541
+ if (!bypassCache && syncHash && !cachedHash) {
1542
+ const dbHash = getPersonalizationHashFromDb(cacheKey);
1543
+ if (dbHash === syncHash) {
1544
+ lastSyncedPersonalizationHashes.set(cacheKey, syncHash);
1545
+ rememberActivePersonalization(cacheKey, instruction, metadata, "db");
1546
+ // Personalization unchanged (DB) - no log needed
1547
+ return true;
1548
+ }
1549
+ }
1550
+
1551
+ let existing = { chars: null, bytes: null, hash: null } as ReturnType<
1552
+ typeof textSize
1553
+ >;
1554
+ // Verifica GET apenas se temos um hash válido (skipped on forceSync)
1555
+ if (!bypassCache && syncHash && !cachedHash && config.qwen.personalizationVerifyGet) {
1556
+ try {
1557
+ const { json: existingJson } =
1558
+ await requestQwenPersonalizationInBrowser(
1559
+ accountId,
1560
+ "GET",
1561
+ "/api/v2/users/user/settings",
1562
+ requestHeaders,
1563
+ );
1564
+ currentSettings = existingJson?.data ?? null;
1565
+ payload = buildQwenSettingsUpdatePayload(currentSettings, instruction);
1566
+ existing = textSize(existingJson?.data?.personalization?.instruction);
1567
+ const existingSafeSettingsApplied =
1568
+ existingJson?.data?.ui?.largeTextAsFile === false &&
1569
+ existingJson?.data?.ui?.splitLargeChunks === false &&
1570
+ existingJson?.data?.ui?.autoTags === false &&
1571
+ existingJson?.data?.mcp_remind === false &&
1572
+ existingJson?.data?.memory?.enable_memory === false &&
1573
+ existingJson?.data?.memory?.enable_history_memory === false &&
1574
+ existingJson?.data?.tools_enabled?.web_search === false &&
1575
+ existingJson?.data?.tools_enabled?.code_interpreter === false;
1576
+ if (existing.hash === sent.hash && existingSafeSettingsApplied) {
1577
+ lastSyncedPersonalizationHashes.set(cacheKey, syncHash);
1578
+ setPersonalizationHashInDb(cacheKey, syncHash);
1579
+ rememberActivePersonalization(
1580
+ cacheKey,
1581
+ instruction,
1582
+ metadata,
1583
+ "verified",
1584
+ );
1585
+ // Personalization unchanged (verified) - no log needed
1586
+ logger.debug("[Qwen] personalization sync skipped after GET", {
1587
+ accountId: cacheKey,
1588
+ model: metadata.model || null,
1589
+ tools: metadata.toolsCount ?? 0,
1590
+ promptChars: metadata.promptChars ?? null,
1591
+ sessionId: metadata.sessionId ?? null,
1592
+ sent,
1593
+ existing,
1594
+ });
1595
+ return true;
1596
+ }
1597
+ } catch (err) {
1598
+ logger.debug("[Qwen] personalization pre-check failed; updating anyway", {
1599
+ accountId: cacheKey,
1600
+ error: err instanceof Error ? err.message : String(err),
1601
+ });
1602
+ }
1603
+ }
1604
+
1605
+ // Helper: attempt the POST, returns { raw, json } or throws on non-retriable errors
1606
+ async function attemptPost(
1607
+ headers: Record<string, string>,
1608
+ ): Promise<{ raw: string; json: any }> {
1609
+ if (!currentSettings) {
1610
+ try {
1611
+ const { json: settingsJson } =
1612
+ await requestQwenPersonalizationInBrowser(
1613
+ accountId,
1614
+ "GET",
1615
+ "/api/v2/users/user/settings",
1616
+ headers,
1617
+ );
1618
+ currentSettings = settingsJson?.data ?? null;
1619
+ payload = buildQwenSettingsUpdatePayload(currentSettings, instruction);
1620
+ } catch (err) {
1621
+ logger.debug(
1622
+ "[Qwen] settings GET before update failed; using safe partial payload",
1623
+ {
1624
+ accountId: cacheKey,
1625
+ error: err instanceof Error ? err.message : String(err),
1626
+ },
1627
+ );
1628
+ }
1629
+ }
1630
+
1631
+ return requestQwenPersonalizationInBrowser(
1632
+ accountId,
1633
+ "POST",
1634
+ "/api/v2/users/user/settings/update",
1635
+ headers,
1636
+ payload,
1637
+ );
1638
+ }
1639
+
1640
+ let raw: string;
1641
+ let json: any;
1642
+
1643
+ // Layer 1: First attempt. attemptPost MUST receive the fully-built request
1644
+ // headers (Content-Type: application/json, Origin, Referer, source, ...) —
1645
+ // the raw getQwenHeaders map lacks Content-Type, and the Qwen API rejects a
1646
+ // body POSTed without it with RequestValidationError ("Field '': Input
1647
+ // should be a valid dictionary..." — the body is not parsed as a JSON object).
1648
+ ({ raw, json } = await attemptPost(requestHeaders));
1649
+
1650
+ // Layer 2: On 401/Unauthorized → refresh session and retry once
1651
+ const isUnauthorized =
1652
+ json?.success === false &&
1653
+ (json?.data?.code === "Unauthorized" ||
1654
+ json?.data?.code === "unauthorized" ||
1655
+ (typeof json?.data?.details === "string" &&
1656
+ json.data.details.includes("401")));
1657
+
1658
+ if (isUnauthorized) {
1659
+ console.warn(
1660
+ `[Qwen] Personalization 401 — refreshing session and retrying | account=${cacheKey}`,
1661
+ );
1662
+ try {
1663
+ const { headers: freshHeaders } = await getQwenHeaders(true, accountId);
1664
+ requestHeaders = buildCapturedQwenHeaders(freshHeaders, {
1665
+ referer: qwenUrl("/settings/personalization"),
1666
+ });
1667
+ ({ raw, json } = await attemptPost(requestHeaders));
1668
+ } catch (retryErr) {
1669
+ // Layer 3: Retry failed → non-fatal, continue without personalization
1670
+ console.warn(
1671
+ `[Qwen] Personalization retry failed, continuing without it | account=${cacheKey} | error=${(retryErr as Error).message?.substring(0, 150)}`,
1672
+ );
1673
+ return false;
1674
+ }
1675
+ }
1676
+
1677
+ // Layer 3: Check final result — non-fatal on failure
1678
+ if (json?.success === false) {
1679
+ console.warn(
1680
+ `[Qwen] Personalization sync failed (non-fatal) | account=${cacheKey} | response=${raw.slice(0, 200)}`,
1681
+ );
1682
+ return false;
1683
+ }
1684
+
1685
+ const returnedInstruction = json?.data?.personalization?.instruction;
1686
+ const returned = textSize(returnedInstruction);
1687
+ let stored = { chars: null, bytes: null, hash: null } as ReturnType<
1688
+ typeof textSize
1689
+ >;
1690
+
1691
+ if (config.qwen.personalizationVerifyGet) {
1692
+ const { json: verifyJson } =
1693
+ await requestQwenPersonalizationInBrowser(
1694
+ accountId,
1695
+ "GET",
1696
+ "/api/v2/users/user/settings",
1697
+ requestHeaders,
1698
+ );
1699
+ stored = textSize(verifyJson?.data?.personalization?.instruction);
1700
+ }
1701
+
1702
+ const matchReturned = returned.hash !== null && returned.hash === sent.hash;
1703
+ const matchStored = stored.hash === null ? null : stored.hash === sent.hash;
1704
+ const applied = matchReturned || matchStored === true;
1705
+ if (syncHash && applied) {
1706
+ lastSyncedPersonalizationHashes.set(cacheKey, syncHash);
1707
+ setPersonalizationHashInDb(cacheKey, syncHash);
1708
+ rememberActivePersonalization(cacheKey, instruction, metadata, "synced");
1709
+ }
1710
+
1711
+ if (!applied) {
1712
+ logger.warn("[Qwen] personalization response did not confirm the requested instructions", {
1713
+ accountId: cacheKey,
1714
+ model: metadata.model || null,
1715
+ tools: metadata.toolsCount ?? 0,
1716
+ sent,
1717
+ returned,
1718
+ stored,
1719
+ });
1720
+ return false;
1721
+ }
1722
+
1723
+ console.log(
1724
+ `✅ [Qwen] Personalization synced | account=${shortAccountId(cacheKey)} | model=${metadata.model || "?"} | tools=${metadata.toolsCount ?? 0} | prompt_chars=${sent.chars ?? 0}${metadata.sessionId ? ` | chat=${metadata.sessionId.substring(0, 12)}` : ""}${matchStored === null ? "" : ` | verified=${matchStored}`}`,
1725
+ );
1726
+ logger.debug("[Qwen] personalization sync details", {
1727
+ accountId: cacheKey,
1728
+ model: metadata.model || null,
1729
+ tools: metadata.toolsCount ?? 0,
1730
+ promptChars: metadata.promptChars ?? null,
1731
+ sessionId: metadata.sessionId ?? null,
1732
+ sent,
1733
+ returned,
1734
+ existing,
1735
+ stored,
1736
+ matchReturned,
1737
+ matchStored,
1738
+ });
1739
+ return true;
1740
+ }
1741
+
1742
+ const DISABLE_TOOLS_MAX_RETRIES = 3;
1743
+ const DISABLE_TOOLS_BACKOFF_MS = 2000;
1744
+
1745
+ export async function disableNativeTools(accountId?: string): Promise<void> {
1746
+ const cacheKey = accountId || "global";
1747
+ if (
1748
+ nativeToolsDisabled.has(cacheKey) ||
1749
+ disablingNativeToolsInProgress.has(cacheKey)
1750
+ ) {
1751
+ return;
1752
+ }
1753
+ disablingNativeToolsInProgress.add(cacheKey);
1754
+
1755
+ try {
1756
+ // Apply the FULL safe-settings patch (tools_enabled + ui + memory +
1757
+ // mcp_remind), not just tools_enabled: since the personalization POST
1758
+ // became personalization-only, nothing else applies ui/memory/mcp_remind,
1759
+ // and the sync's verified-cache check (existingSafeSettingsApplied)
1760
+ // requires ALL of them false. A live probe confirmed the combined
1761
+ // no-personalization payload is accepted by settings/update.
1762
+ const payload = QWEN_SAFE_SETTINGS_PATCH;
1763
+
1764
+ // Use an isolated page only when the main page is actively serving a stream.
1765
+ // Startup/idle operations should not open a visible extra tab.
1766
+ if (accountId && !isAuthMockEnabled() && isAccountBusy(accountId)) {
1767
+ try {
1768
+ const result = await withAccountPage(
1769
+ accountId,
1770
+ async (page) => {
1771
+ const response = await page.evaluate(
1772
+ async ({ payload, timeoutMs }: { payload: any; timeoutMs: number }) => {
1773
+ const controller = new AbortController();
1774
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1775
+ try {
1776
+ const resp = await fetch(
1777
+ "https://chat.qwen.ai/api/v2/users/user/settings/update",
1778
+ {
1779
+ method: "POST",
1780
+ headers: {
1781
+ accept: "application/json, text/plain, */*",
1782
+ "content-type": "application/json",
1783
+ "x-request-id": crypto.randomUUID(),
1784
+ timezone: new Date().toString().split(" (")[0],
1785
+ source: "web",
1786
+ },
1787
+ body: JSON.stringify(payload),
1788
+ signal: controller.signal,
1789
+ },
1790
+ );
1791
+ return { status: resp.status, body: await resp.text() };
1792
+ } finally {
1793
+ clearTimeout(timeoutId);
1794
+ }
1795
+ },
1796
+ { payload, timeoutMs: config.timeouts.http },
1797
+ );
1798
+ return response;
1799
+ },
1800
+ );
1801
+ if (result.status < 400) {
1802
+ nativeToolsDisabled.add(cacheKey);
1803
+ return;
1804
+ }
1805
+ console.warn(
1806
+ `⚠️ [Qwen] Isolated disableNativeTools returned ${result.status} for ${cacheKey}`,
1807
+ );
1808
+ } catch (error) {
1809
+ // Fall through to standard request path
1810
+ logger.debug("[Qwen] Isolated disableNativeTools failed, using standard path", {
1811
+ accountId: cacheKey,
1812
+ error: error instanceof Error ? error.message : String(error),
1813
+ });
1814
+ }
1815
+ }
1816
+
1817
+ // Fallback: standard request path
1818
+ const { headers } = await getQwenHeaders(false, accountId);
1819
+ const requestHeaders = buildCapturedQwenHeaders(headers, {
1820
+ referer: qwenUrl("/settings/personalization"),
1821
+ });
1822
+
1823
+ let lastError: string | null = null;
1824
+ for (let attempt = 1; attempt <= DISABLE_TOOLS_MAX_RETRIES; attempt++) {
1825
+ try {
1826
+ const response = await requestQwenTextInBrowser(
1827
+ accountId,
1828
+ "POST",
1829
+ "/api/v2/users/user/settings/update",
1830
+ requestHeaders,
1831
+ JSON.stringify(payload),
1832
+ {
1833
+ settingsPage: true,
1834
+ referrer: qwenUrl("/settings/personalization"),
1835
+ },
1836
+ );
1837
+
1838
+ if (!response.ok) {
1839
+ const text = await response.text();
1840
+ lastError = `${response.status} - ${text}`;
1841
+ console.warn(
1842
+ `⚠️ [Qwen] Failed to disable native tools for ${cacheKey} (attempt ${attempt}/${DISABLE_TOOLS_MAX_RETRIES}): ${lastError}`,
1843
+ );
1844
+ } else {
1845
+ nativeToolsDisabled.add(cacheKey);
1846
+ return;
1847
+ }
1848
+ } catch (err: any) {
1849
+ lastError = err.message;
1850
+ console.warn(
1851
+ `[Qwen] Error disabling native tools for ${cacheKey} (attempt ${attempt}/${DISABLE_TOOLS_MAX_RETRIES}): ${lastError}`,
1852
+ );
1853
+ }
1854
+
1855
+ if (attempt < DISABLE_TOOLS_MAX_RETRIES) {
1856
+ const backoff = DISABLE_TOOLS_BACKOFF_MS * attempt;
1857
+ console.log(
1858
+ `🔄 [Qwen] Retrying disable native tools in ${backoff}ms...`,
1859
+ );
1860
+ await new Promise((r) => setTimeout(r, backoff));
1861
+ }
1862
+ }
1863
+
1864
+ console.error(
1865
+ `[Qwen] Failed to disable native tools for ${cacheKey} after ${DISABLE_TOOLS_MAX_RETRIES} attempts. Last error: ${lastError}`,
1866
+ );
1867
+ } finally {
1868
+ disablingNativeToolsInProgress.delete(cacheKey);
1869
+ }
1870
+ }
1871
+
1872
+ function asModelRecord(value: unknown): Record<string, unknown> {
1873
+ return value && typeof value === "object" && !Array.isArray(value)
1874
+ ? (value as Record<string, unknown>)
1875
+ : {};
1876
+ }
1877
+
1878
+ /**
1879
+ * Keep the upstream model object intact while adding stable normalized aliases.
1880
+ * The registry consumes `info.meta`, and adapters can still inspect fields Qwen
1881
+ * adds in the future without another parser change.
1882
+ */
1883
+ function formatPublicQwenModel(model: Record<string, unknown>): PublicQwenModel {
1884
+ const info = asModelRecord(model.info);
1885
+ const metadata = {
1886
+ ...asModelRecord(model.metadata),
1887
+ ...asModelRecord(model.meta),
1888
+ ...asModelRecord(info.meta),
1889
+ };
1890
+ const capabilities = {
1891
+ ...asModelRecord(metadata.capabilities),
1892
+ ...asModelRecord(info.capabilities),
1893
+ ...asModelRecord(model.capabilities),
1894
+ };
1895
+ const id = typeof model.id === "string" ? model.id : "";
1896
+ const name =
1897
+ (typeof model.name === "string" && model.name) ||
1898
+ (typeof info.name === "string" && info.name) ||
1899
+ id;
1900
+ const createdValue = info.created_at ?? model.created;
1901
+ const created =
1902
+ typeof createdValue === "number" && Number.isFinite(createdValue)
1903
+ ? createdValue
1904
+ : Date.now();
1905
+ const contextWindow =
1906
+ typeof metadata.max_context_length === "number"
1907
+ ? metadata.max_context_length
1908
+ : undefined;
1909
+ const modality = Array.isArray(metadata.modality)
1910
+ ? metadata.modality.filter((value): value is string => typeof value === "string")
1911
+ : undefined;
1912
+ const chatType = Array.isArray(metadata.chat_type)
1913
+ ? metadata.chat_type.filter((value): value is string => typeof value === "string")
1914
+ : undefined;
1915
+ const isActive =
1916
+ typeof info.is_active === "boolean"
1917
+ ? info.is_active
1918
+ : typeof model.is_active === "boolean"
1919
+ ? model.is_active
1920
+ : undefined;
1921
+
1922
+ return {
1923
+ ...model,
1924
+ id,
1925
+ name,
1926
+ object: "model",
1927
+ owned_by:
1928
+ (typeof model.owned_by === "string" && model.owned_by) || "qwen",
1929
+ created,
1930
+ ...(contextWindow !== undefined ? { context_window: contextWindow } : {}),
1931
+ capabilities,
1932
+ metadata,
1933
+ info,
1934
+ meta: metadata,
1935
+ ...(modality ? { modality } : {}),
1936
+ ...(chatType ? { chat_type: chatType } : {}),
1937
+ ...(metadata.think_skip && typeof metadata.think_skip === "object"
1938
+ ? { think_skip: metadata.think_skip as Record<string, unknown> }
1939
+ : {}),
1940
+ ...(isActive !== undefined ? { is_active: isActive } : {}),
1941
+ ...(metadata.max_summary_generation_length !== undefined
1942
+ ? { max_summary_generation_length: metadata.max_summary_generation_length }
1943
+ : {}),
1944
+ ...(metadata.max_thinking_generation_length !== undefined
1945
+ ? {
1946
+ max_thinking_generation_length:
1947
+ metadata.max_thinking_generation_length,
1948
+ }
1949
+ : {}),
1950
+ };
1951
+ }
1952
+
1953
+ export async function deleteAllQwenChats(accountId?: string): Promise<boolean> {
1954
+ const { headers } = await getQwenHeaders(false, accountId);
1955
+ const response = await requestQwenTextInBrowser(
1956
+ accountId,
1957
+ "DELETE",
1958
+ "/api/v2/chats/",
1959
+ buildCapturedQwenHeaders(headers, {
1960
+ referer: qwenUrl("/settings/chats"),
1961
+ }),
1962
+ undefined,
1963
+ { referrer: qwenUrl("/settings/chats") },
1964
+ );
1965
+
1966
+ const { raw, json: parsed } = await readJsonTextResponse(response, {
1967
+ strict: true,
1968
+ });
1969
+ if (!response.ok) {
1970
+ throw new Error(
1971
+ `Failed to delete chats from Qwen: ${response.status} ${raw.substring(0, 200)}`,
1972
+ );
1973
+ }
1974
+
1975
+ const success = parsed?.success === true && parsed?.data?.status === true;
1976
+ if (!success) {
1977
+ throw new Error(
1978
+ `Qwen delete chats returned unexpected payload: ${raw.substring(0, 200)}`,
1979
+ );
1980
+ }
1981
+
1982
+ clearAllSessionsForAccount(accountId || "global");
1983
+ return true;
1984
+ }
1985
+
1986
+ export async function fetchQwenModels(
1987
+ accountId?: string,
1988
+ ): Promise<PublicQwenModel[]> {
1989
+ const cacheKey = accountId || "global";
1990
+ const now = Date.now();
1991
+ const cached = modelsCache.get(cacheKey);
1992
+ if (cached && now - cached.fetchedAt < MODEL_CACHE_TTL_MS) {
1993
+ syncModelMetadata(
1994
+ cached.models as unknown as Array<Record<string, unknown> & { id: string }>,
1995
+ accountId,
1996
+ );
1997
+ return cached.models;
1998
+ }
1999
+
2000
+ // Use an isolated page only when the main page is actively serving a stream.
2001
+ // Startup/idle operations should not open a visible extra tab.
2002
+ if (accountId && !isAuthMockEnabled() && isAccountBusy(accountId)) {
2003
+ try {
2004
+ const result = await withAccountPage(
2005
+ accountId,
2006
+ async (page) => {
2007
+ const response = await page.evaluate(async (timeoutMs: number) => {
2008
+ const controller = new AbortController();
2009
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2010
+ try {
2011
+ const resp = await fetch("https://chat.qwen.ai/api/models", {
2012
+ method: "GET",
2013
+ headers: {
2014
+ accept: "application/json, text/plain, */*",
2015
+ "x-request-id": crypto.randomUUID(),
2016
+ timezone: new Date().toString().split(" (")[0],
2017
+ source: "web",
2018
+ },
2019
+ signal: controller.signal,
2020
+ });
2021
+ return { status: resp.status, body: await resp.text() };
2022
+ } finally {
2023
+ clearTimeout(timeoutId);
2024
+ }
2025
+ }, config.timeouts.http);
2026
+ return response;
2027
+ },
2028
+ );
2029
+ if (result.status < 400) {
2030
+ const json = JSON.parse(result.body);
2031
+ if (json.data && Array.isArray(json.data)) {
2032
+ const models = json.data
2033
+ .filter((model: unknown) => {
2034
+ const record = asModelRecord(model);
2035
+ return typeof record.id === "string" && record.id.trim().length > 0;
2036
+ })
2037
+ .map((model: unknown) => formatPublicQwenModel(asModelRecord(model)));
2038
+
2039
+ replaceModelMetadata(
2040
+ models as unknown as Array<Record<string, unknown> & { id: string }>,
2041
+ accountId,
2042
+ );
2043
+ modelsCache.set(cacheKey, { models, fetchedAt: now });
2044
+ return models;
2045
+ }
2046
+ }
2047
+ } catch (error) {
2048
+ // Fall through to standard request path
2049
+ logger.debug("[Qwen] Isolated model fetch failed, using standard path", {
2050
+ accountId: cacheKey,
2051
+ error: error instanceof Error ? error.message : String(error),
2052
+ });
2053
+ }
2054
+ }
2055
+
2056
+ const { cookie, userAgent, bxV, bxUa, bxUmidtoken } =
2057
+ await getBasicHeaders(accountId);
2058
+
2059
+ const response = await requestQwenTextInBrowser(
2060
+ accountId,
2061
+ "GET",
2062
+ "/api/models",
2063
+ buildQwenRequestHeaders({
2064
+ cookie,
2065
+ userAgent,
2066
+ bxV,
2067
+ bxUa,
2068
+ bxUmidtoken,
2069
+ extra: {
2070
+ timezone: new Date().toString(),
2071
+ },
2072
+ }),
2073
+ undefined,
2074
+ { referrer: qwenUrl("/") },
2075
+ );
2076
+
2077
+ if (!response.ok) {
2078
+ throw new Error(
2079
+ `Failed to fetch models from Qwen: ${response.status} ${response.statusText}`,
2080
+ );
2081
+ }
2082
+
2083
+ const json = await response.json();
2084
+ if (json.data && Array.isArray(json.data)) {
2085
+ // Keep only upstream/base entries here. The public `-fast` variant is
2086
+ // generated exactly once by the public models endpoint after metadata has
2087
+ // been synchronized.
2088
+ const models = json.data
2089
+ .filter((model: unknown) => {
2090
+ const record = asModelRecord(model);
2091
+ return typeof record.id === "string" && record.id.trim().length > 0;
2092
+ })
2093
+ .map((model: unknown) => formatPublicQwenModel(asModelRecord(model)));
2094
+
2095
+ replaceModelMetadata(
2096
+ models as unknown as Array<Record<string, unknown> & { id: string }>,
2097
+ accountId,
2098
+ );
2099
+ modelsCache.set(cacheKey, { models, fetchedAt: now });
2100
+ return models;
2101
+ }
2102
+
2103
+ return [];
2104
+ }
2105
+
2106
+ export interface QwenFileEntry {
2107
+ type: string;
2108
+ file: any;
2109
+ id: string;
2110
+ url: string;
2111
+ name: string;
2112
+ [key: string]: any;
2113
+ }
2114
+
2115
+
2116
+ function isQwenChatNotExistMessage(details: string): boolean {
2117
+ return (
2118
+ details.includes("is not exist") ||
2119
+ details.includes("not exist") ||
2120
+ details.includes("does not exist")
2121
+ );
2122
+ }
2123
+
2124
+ function isQwenQuotaLimitMessage(details: string): boolean {
2125
+ const normalized = details.toLowerCase();
2126
+ return (
2127
+ normalized.includes("allocated quota exceeded") ||
2128
+ normalized.includes("quota exceeded") ||
2129
+ normalized.includes("increase your quota") ||
2130
+ normalized.includes("token-limit") ||
2131
+ normalized.includes("insufficient quota") ||
2132
+ normalized.includes("rate limit") ||
2133
+ normalized.includes("ratelimited")
2134
+ );
2135
+ }
2136
+
2137
+ function parseQwenJsonError(
2138
+ raw: string,
2139
+ status: number,
2140
+ accountId?: string,
2141
+ ): Error | null {
2142
+ let errorJson: any;
2143
+ try {
2144
+ errorJson = JSON.parse(raw);
2145
+ } catch {
2146
+ return null;
2147
+ }
2148
+
2149
+
2150
+
2151
+ const retryDelay = (attempt: number) => {
2152
+ const base = config.retry.baseDelayMs;
2153
+ const max = config.retry.maxDelayMs;
2154
+ const exp = Math.min(base * Math.pow(2, attempt - 1), max);
2155
+ const jitter = exp * 0.3 * Math.random();
2156
+ return Math.floor(exp + jitter);
2157
+ };
2158
+
2159
+ // Anti-bot detection: {ret: ["FAIL_SYS_USER_VALIDATE", ...]} format
2160
+ const retArray: string[] | undefined = errorJson?.ret;
2161
+ if (Array.isArray(retArray)) {
2162
+ const retStr = retArray.join(",");
2163
+ if (
2164
+ retStr.includes("FAIL_SYS_USER_VALIDATE") ||
2165
+ retStr.includes("RGV587_ERROR")
2166
+ ) {
2167
+ const error = new RetryableQwenStreamError(
2168
+ `Qwen anti-bot: ${retStr.substring(0, 200)}`,
2169
+ 0,
2170
+ );
2171
+ error.upstreamCode = "waf_challenge";
2172
+ return error;
2173
+ }
2174
+ }
2175
+
2176
+ const details =
2177
+ errorJson?.data?.details ||
2178
+ errorJson?.message ||
2179
+ errorJson?.error?.message ||
2180
+ "Qwen returned an error";
2181
+
2182
+ if (typeof details === "string" && isQwenChatNotExistMessage(details)) {
2183
+ const attempt = errorJson?.data?.retryCount ?? 1;
2184
+ const error = new RetryableQwenStreamError(
2185
+ `Qwen: ${details}`,
2186
+ retryDelay(attempt),
2187
+ );
2188
+ error.upstreamCode = "chat_not_exist";
2189
+ return error;
2190
+ }
2191
+
2192
+ // Anti-bot detection: FAIL_SYS_USER_VALIDATE / RGV587_ERROR
2193
+ if (
2194
+ typeof details === "string" &&
2195
+ (details.includes("FAIL_SYS_USER_VALIDATE") ||
2196
+ details.includes("RGV587_ERROR") ||
2197
+ details.includes("user validate"))
2198
+ ) {
2199
+ const error = new RetryableQwenStreamError(
2200
+ `Qwen anti-bot: ${details}`,
2201
+ 0,
2202
+ );
2203
+ error.upstreamCode = "waf_challenge";
2204
+ return error;
2205
+ }
2206
+
2207
+ if (
2208
+ typeof details === "string" &&
2209
+ (details.toLowerCase().includes("chat is in progress") ||
2210
+ details.toLowerCase().includes("the chat is in progress"))
2211
+ ) {
2212
+ const attempt = errorJson?.data?.retryCount ?? 1;
2213
+ const error = new RetryableQwenStreamError(
2214
+ `Qwen: ${details}`,
2215
+ retryDelay(attempt),
2216
+ );
2217
+ error.upstreamCode = "chat_in_progress";
2218
+ return error;
2219
+ }
2220
+
2221
+ if (errorJson?.success === false) {
2222
+ const code = errorJson.data?.code || errorJson.code || "UpstreamError";
2223
+
2224
+ if (
2225
+ status === 401 ||
2226
+ code === "Unauthorized" ||
2227
+ (typeof details === "string" &&
2228
+ (details.includes("login") || details.includes("session")))
2229
+ ) {
2230
+ return new QwenSessionExpiredError(
2231
+ `Session expired: ${details}`,
2232
+ accountId || "global",
2233
+ );
2234
+ }
2235
+
2236
+ const wait =
2237
+ errorJson.data?.num !== undefined
2238
+ ? ` Wait about ${errorJson.data.num} hour(s) before trying again.`
2239
+ : "";
2240
+ const message = `Qwen upstream error: ${code}: ${details}.${wait}`;
2241
+
2242
+ if (
2243
+ code === "RateLimited" ||
2244
+ status === 429 ||
2245
+ (typeof details === "string" && isQwenQuotaLimitMessage(details))
2246
+ ) {
2247
+ return new UpstreamRateLimit(message);
2248
+ }
2249
+
2250
+ const upstreamStatus = code === "Not_Found" ? 404 : 502;
2251
+ return new QwenUpstreamError(message, code, upstreamStatus);
2252
+ }
2253
+
2254
+ if (errorJson?.error) {
2255
+ const message =
2256
+ typeof errorJson.error === "string"
2257
+ ? errorJson.error
2258
+ : errorJson.error.message || JSON.stringify(errorJson.error);
2259
+ if (isQwenQuotaLimitMessage(message)) {
2260
+ return new UpstreamRateLimit(`Qwen upstream error: ${message}`);
2261
+ }
2262
+
2263
+ return new QwenUpstreamError(
2264
+ `Qwen upstream error: ${message}`,
2265
+ "UpstreamError",
2266
+ 502,
2267
+ );
2268
+ }
2269
+
2270
+ return null;
2271
+ }
2272
+
2273
+ const UPSTREAM_RESPONSE_PREVIEW_BYTES = 8 * 1024;
2274
+
2275
+ function isHtmlResponseContentType(contentType: string): boolean {
2276
+ const normalized = contentType.toLowerCase();
2277
+ return (
2278
+ normalized.includes("text/html") ||
2279
+ normalized.includes("application/xhtml+xml")
2280
+ );
2281
+ }
2282
+
2283
+ function isHtmlResponseBody(value: string): boolean {
2284
+ return /^\s*(?:<!doctype\s+html|<html\b)/i.test(value);
2285
+ }
2286
+
2287
+ function isWafChallengeResponse(value: string): boolean {
2288
+ const normalized = value.toLowerCase();
2289
+ return (
2290
+ normalized.includes("aliyun_waf") ||
2291
+ normalized.includes("_____tmd_____") ||
2292
+ normalized.includes("fail_sys_user_validate") ||
2293
+ normalized.includes("rgv587_error") ||
2294
+ normalized.includes("denyfromx5") ||
2295
+ normalized.includes("captcha") ||
2296
+ normalized.includes("security verification")
2297
+ );
2298
+ }
2299
+
2300
+ async function readResponsePreview(
2301
+ response: Response,
2302
+ maxBytes = UPSTREAM_RESPONSE_PREVIEW_BYTES,
2303
+ ): Promise<string> {
2304
+ if (!response.body) return "";
2305
+
2306
+ const reader = response.body.getReader();
2307
+ const chunks: Buffer[] = [];
2308
+ let bytesRead = 0;
2309
+
2310
+ try {
2311
+ while (bytesRead < maxBytes) {
2312
+ const { done, value } = await reader.read();
2313
+ if (done) break;
2314
+ if (!value) continue;
2315
+
2316
+ const chunk = Buffer.from(value);
2317
+ const remaining = maxBytes - bytesRead;
2318
+ if (chunk.byteLength > remaining) {
2319
+ chunks.push(chunk.subarray(0, remaining));
2320
+ bytesRead += remaining;
2321
+ break;
2322
+ }
2323
+ chunks.push(chunk);
2324
+ bytesRead += chunk.byteLength;
2325
+ }
2326
+ } finally {
2327
+ await reader.cancel().catch(() => undefined);
2328
+ }
2329
+
2330
+ return Buffer.concat(chunks).toString("utf8");
2331
+ }
2332
+
2333
+ /**
2334
+ * Headers for the completions POST (browser relay). Reuses
2335
+ * buildCapturedQwenHeaders (cookie, origin, referer with the chat id,
2336
+ * sec-ch-ua, version, bx-v, source) and additionally injects
2337
+ * bx-ua/bx-umidtoken when captured — the real client POSTs completions WITH
2338
+ * them (0.2.86 HAR, network/), so the relay matches the browser client.
2339
+ *
2340
+ * NOTE: a direct Node-side fetch was tried and removed — the Qwen WAF
2341
+ * fingerprints the HTTP stack beyond headers and JA3 (live probes: blocked
2342
+ * with no headers at all AND with a chrome_136 TLS profile), so completions
2343
+ * must go through the browser. The settings endpoints have no such WAF and
2344
+ * use direct Node fetch (requestQwenSettingsDirectFetch).
2345
+ */
2346
+ export function buildCompletionHeaders(
2347
+ headers: Record<string, string>,
2348
+ chatSessionId: string | null | undefined,
2349
+ ): Record<string, string> {
2350
+ const base = buildCapturedQwenHeaders(headers, {
2351
+ chatSessionId: chatSessionId || null,
2352
+ extra: {
2353
+ "x-accel-buffering": "no",
2354
+ },
2355
+ });
2356
+ if (headers["bx-ua"]) base["bx-ua"] = headers["bx-ua"];
2357
+ if (headers["bx-umidtoken"]) base["bx-umidtoken"] = headers["bx-umidtoken"];
2358
+ return base;
2359
+ }
2360
+
2361
+ export async function createQwenStream(
2362
+ prompt: string,
2363
+ enableThinking: boolean,
2364
+ modelId: string,
2365
+ forcedParentId?: string | null,
2366
+ accountId?: string,
2367
+ files?: QwenFileEntry[],
2368
+ options?: {
2369
+ chatSessionId?: string | null;
2370
+ forceNewChat?: boolean;
2371
+ reasoningMode?: "auto" | "thinking" | "fast";
2372
+ /** Auxiliary stream (title on its own chat): short idle cap. */
2373
+ parallelEscape?: boolean;
2374
+ /** "thread" (chat_mode:"normal") or "temp" (chat_mode:"local"). */
2375
+ chatMode?: ChatMode;
2376
+ },
2377
+ signal?: AbortSignal,
2378
+ ): Promise<{
2379
+ stream: ReadableStream;
2380
+ headers: Record<string, string>;
2381
+ uiSessionId: string;
2382
+ controller: AbortController;
2383
+ accountId: string;
2384
+ createdNewChat: boolean;
2385
+ tokenEstimationContext: TokenEstimationContext;
2386
+ }> {
2387
+ if (signal?.aborted) {
2388
+ throw new Error("client aborted before stream creation");
2389
+ }
2390
+ // Take a stream slot for the account (up to maxStreamsPerAccount concurrent;
2391
+ // the relay multiplexes them on the page via reqId).
2392
+ const streamLockKey = accountId || "global";
2393
+ const startedAt = Date.now();
2394
+ const releaseStreamLock = await acquireAccountStreamLock(streamLockKey);
2395
+ if (logger.isLevelEnabled("info")) {
2396
+ console.log(
2397
+ `⏱️ [Qwen] Create: stream-lock | account=${accountId ?? "global"} | +${Date.now() - startedAt}ms`,
2398
+ );
2399
+ }
2400
+ let streamLockReleased = false;
2401
+ const releaseStreamLockOnce = () => {
2402
+ if (streamLockReleased) return;
2403
+ streamLockReleased = true;
2404
+ releaseStreamLock();
2405
+ };
2406
+
2407
+ // A signal can fire while this attempt waited in the stream-lock queue
2408
+ // (client disconnect, same-session supersede, or the acquire deadline
2409
+ // aborting the race loser). Re-check AFTER the lock: a race-lost orphan must
2410
+ // not proceed to make a full upstream request while unobserved (it would hold
2411
+ // the lock for minutes and burn a Qwen request).
2412
+ if (signal?.aborted) {
2413
+ releaseStreamLockOnce();
2414
+ throw new Error("client aborted before stream creation");
2415
+ }
2416
+
2417
+ try {
2418
+ return await createQwenStreamInternal(
2419
+ prompt,
2420
+ enableThinking,
2421
+ modelId,
2422
+ forcedParentId,
2423
+ accountId,
2424
+ files,
2425
+ options,
2426
+ signal,
2427
+ releaseStreamLockOnce,
2428
+ startedAt,
2429
+ );
2430
+ } catch (error) {
2431
+ releaseStreamLockOnce();
2432
+ throw error;
2433
+ }
2434
+ }
2435
+
2436
+ async function createQwenStreamInternal(
2437
+ prompt: string,
2438
+ enableThinking: boolean,
2439
+ modelId: string,
2440
+ forcedParentId: string | null | undefined,
2441
+ accountId: string | undefined,
2442
+ files: QwenFileEntry[] | undefined,
2443
+ options: {
2444
+ chatSessionId?: string | null;
2445
+ forceNewChat?: boolean;
2446
+ reasoningMode?: "auto" | "thinking" | "fast";
2447
+ /** Auxiliary stream (title on its own chat): short idle cap. */
2448
+ parallelEscape?: boolean;
2449
+ /** "thread" (chat_mode:"normal") or "temp" (chat_mode:"local"). */
2450
+ chatMode?: ChatMode;
2451
+ } | undefined,
2452
+ signal: AbortSignal | undefined,
2453
+ releaseStreamLock: () => void,
2454
+ /** Wall-clock start of the acquire (used for per-phase +Xms telemetry). */
2455
+ startedAt: number,
2456
+ ): Promise<{
2457
+ stream: ReadableStream;
2458
+ headers: Record<string, string>;
2459
+ uiSessionId: string;
2460
+ controller: AbortController;
2461
+ accountId: string;
2462
+ createdNewChat: boolean;
2463
+ tokenEstimationContext: TokenEstimationContext;
2464
+ }> {
2465
+ const ensureNotAborted = () => {
2466
+ if (signal?.aborted) {
2467
+ // Aborted by the client OR by a same-session supersede (latest-wins).
2468
+ // Typed as ClientAbortedError so the retry policy treats it as a silent
2469
+ // client abort instead of a retryable stream_aborted — a superseded
2470
+ // request must not resend full context on another account.
2471
+ throw new ClientAbortedError("client aborted before completion request");
2472
+ }
2473
+ };
2474
+
2475
+ const phase = (name: string) => {
2476
+ if (logger.isLevelEnabled("info")) {
2477
+ console.log(
2478
+ `⏱️ [Qwen] Create: ${name} | account=${accountId ?? "global"} | +${Date.now() - startedAt}ms`,
2479
+ );
2480
+ }
2481
+ };
2482
+
2483
+ // A new logical chat session should reuse the warmed header cache when available.
2484
+ // Header recapture is much more expensive and should be reserved for real refresh/login cases,
2485
+ // not for ordinary first prompts that simply need parent_id reset.
2486
+ const captured = await getQwenHeaders(
2487
+ options?.forceNewChat === true,
2488
+ accountId,
2489
+ );
2490
+ ensureNotAborted();
2491
+ phase("headers");
2492
+ const { headers, parentMessageId } = captured;
2493
+ let activeHeaders = headers;
2494
+ // The upstream always receives the real base model ID. Reasoning mode is
2495
+ // selected exclusively by feature_config, not by a synthetic model suffix.
2496
+ const model = mapClientModelToQwen(modelId);
2497
+ let createdNewChat = false;
2498
+ let chatSessionId: string | null | undefined;
2499
+ let leasedWarmChat = false;
2500
+ if (options && "chatSessionId" in options) {
2501
+ if (options.chatSessionId === null || options.chatSessionId === "") {
2502
+ const acquired = await acquireNewQwenChatSession(
2503
+ headers,
2504
+ model,
2505
+ accountId,
2506
+ options?.chatMode ?? "thread",
2507
+ );
2508
+ chatSessionId = acquired.chatId;
2509
+ leasedWarmChat = acquired.leasedFromPool;
2510
+ createdNewChat = true;
2511
+ } else {
2512
+ chatSessionId = options.chatSessionId;
2513
+ }
2514
+ } else {
2515
+ chatSessionId = captured.chatSessionId;
2516
+ if (!chatSessionId) {
2517
+ const acquired = await acquireNewQwenChatSession(
2518
+ headers,
2519
+ model,
2520
+ accountId,
2521
+ options?.chatMode ?? "thread",
2522
+ );
2523
+ chatSessionId = acquired.chatId;
2524
+ leasedWarmChat = acquired.leasedFromPool;
2525
+ createdNewChat = true;
2526
+ }
2527
+ }
2528
+
2529
+ ensureNotAborted();
2530
+ phase("chat");
2531
+
2532
+ let warmChatReleased = false;
2533
+ const releaseLeasedWarmChat = () => {
2534
+ if (!leasedWarmChat || warmChatReleased || !chatSessionId) return;
2535
+ warmChatReleased = true;
2536
+ releaseWarmChat(accountId, model, chatSessionId);
2537
+ };
2538
+
2539
+ // Combined cleanup: release warm chat AND stream lock
2540
+ const releaseStreamResources = () => {
2541
+ releaseLeasedWarmChat();
2542
+ releaseStreamLock();
2543
+ };
2544
+
2545
+ const wrapUpstreamStream = (
2546
+ stream: ReadableStream<Uint8Array>,
2547
+ controller: AbortController,
2548
+ ): ReadableStream<Uint8Array> => {
2549
+ if (config.timeouts.idleStreamTimeout <= 0) {
2550
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
2551
+ return new ReadableStream<Uint8Array>({
2552
+ start() {
2553
+ reader = stream.getReader();
2554
+ },
2555
+ async pull(streamController) {
2556
+ try {
2557
+ if (!reader) throw new Error("Stream reader was not initialized");
2558
+ const { done, value } = await reader.read();
2559
+ if (done) {
2560
+ releaseStreamResources();
2561
+ streamController.close();
2562
+ return;
2563
+ }
2564
+ streamController.enqueue(value);
2565
+ } catch (error) {
2566
+ releaseStreamResources();
2567
+ streamController.error(error);
2568
+ }
2569
+ },
2570
+ cancel(reason) {
2571
+ releaseStreamResources();
2572
+ return stream.cancel(reason);
2573
+ },
2574
+ });
2575
+ }
2576
+
2577
+ // Dynamic idle timeout based on model type and payload size
2578
+ // Reasoning models (thinking enabled): use REASONING_MODEL_TIMEOUT as base
2579
+ // Non-reasoning models: use IDLE_STREAM_TIMEOUT as base
2580
+ // Both add 30s per MB of payload. Parallel-escape streams get a tight cap
2581
+ // ONLY when non-thinking (see computeDynamicIdleTimeout).
2582
+ const baseTimeoutMs = enableThinking
2583
+ ? config.timeouts.reasoningModelTimeout
2584
+ : config.timeouts.idleStreamTimeout;
2585
+ const dynamicIdleTimeoutMs = computeDynamicIdleTimeout({
2586
+ enableThinking,
2587
+ parallelEscape: options?.parallelEscape,
2588
+ baseTimeoutMs,
2589
+ payloadSize,
2590
+ });
2591
+
2592
+ logger.debug("[Qwen] dynamic idle timeout", {
2593
+ chatId: chatSessionId || "new",
2594
+ model: modelId,
2595
+ enableThinking,
2596
+ payloadMB: payloadMB.toFixed(2),
2597
+ baseTimeout: baseTimeoutMs,
2598
+ dynamicTimeout: dynamicIdleTimeoutMs,
2599
+ });
2600
+
2601
+ // Thinking models idle at 600s — fine for gaps AFTER data flows, but a
2602
+ // stream that produced NOTHING in the first-chunk window is dead. Fail
2603
+ // fast (retryable) so the account slot is not held for 10 minutes.
2604
+ const firstChunkDeadlineMs = enableThinking
2605
+ ? Math.max(
2606
+ config.timeouts.firstChunkTimeout,
2607
+ config.timeouts.timeToFirstByte,
2608
+ )
2609
+ : undefined;
2610
+
2611
+ return addIdleTimeoutToStream(
2612
+ stream,
2613
+ controller,
2614
+ dynamicIdleTimeoutMs,
2615
+ `Qwen stream ${chatSessionId || "unknown"}`,
2616
+ releaseStreamResources,
2617
+ releaseStreamResources,
2618
+ firstChunkDeadlineMs,
2619
+ );
2620
+ };
2621
+
2622
+ const withCreatedChatMetadata = <T extends Error>(error: T): T => {
2623
+ if (createdNewChat && chatSessionId) {
2624
+ (error as any).createdNewChat = true;
2625
+ (error as any).chatSessionId = chatSessionId;
2626
+ (error as any).accountId = accountId ?? "global";
2627
+ }
2628
+ return error;
2629
+ };
2630
+
2631
+ let actualParentId: string | null = parentMessageId;
2632
+
2633
+ if (forcedParentId !== undefined) {
2634
+ actualParentId = forcedParentId;
2635
+ if (chatSessionId && forcedParentId === null) {
2636
+ updateSessionParent(chatSessionId, null, accountId ?? "global");
2637
+ }
2638
+ } else if (chatSessionId) {
2639
+ const storedParent = getSessionParent(chatSessionId, accountId ?? "global");
2640
+ if (storedParent !== undefined) {
2641
+ actualParentId = storedParent;
2642
+ }
2643
+ }
2644
+
2645
+ const timestamp = Math.floor(Date.now() / 1000);
2646
+ const fid = uuidv4();
2647
+ const childId = uuidv4();
2648
+
2649
+ const payload: QwenPayload = {
2650
+ stream: true,
2651
+ version: "2.1",
2652
+ incremental_output: true,
2653
+ chatId: chatSessionId || null,
2654
+ parentId: actualParentId ?? "",
2655
+ chat_id: chatSessionId || null,
2656
+ chat_mode: options?.chatMode === "temp" ? "local" : "normal",
2657
+ model: model,
2658
+ parent_id: actualParentId,
2659
+ messages: [
2660
+ {
2661
+ id: null,
2662
+ fid: fid,
2663
+ parentId: actualParentId,
2664
+ childrenIds: [childId],
2665
+ role: "user",
2666
+ content: prompt,
2667
+ user_action: "chat",
2668
+ files: files || [],
2669
+ timestamp: timestamp,
2670
+ models: [model],
2671
+ model: "",
2672
+ chat_type: "t2t",
2673
+ feature_config: (() => {
2674
+ // Determine reasoning mode: explicit option takes precedence, otherwise derive from enableThinking
2675
+ // - reasoningMode="auto": Qwen decides (auto_thinking=true, thinking_mode="Auto")
2676
+ // - reasoningMode="thinking": force thinking ON (thinking_mode="Thinking")
2677
+ // - reasoningMode="fast": force thinking OFF (thinking_mode="Fast")
2678
+ // - No reasoningMode + enableThinking=false: legacy "fast" mode
2679
+ // - No reasoningMode + enableThinking=true: legacy "thinking" mode
2680
+ const mode = options?.reasoningMode ?? (enableThinking ? "thinking" : "fast");
2681
+ const thinkingMode = mode === "thinking" ? "Thinking" : mode === "fast" ? "Fast" : "Auto";
2682
+ const thinkingEnabled = mode !== "fast";
2683
+ return {
2684
+ thinking_enabled: thinkingEnabled,
2685
+ output_schema: "phase",
2686
+ research_mode: "normal",
2687
+ auto_thinking: mode === "auto",
2688
+ thinking_mode: thinkingMode,
2689
+ ...(thinkingEnabled ? { thinking_format: "summary" } : {}),
2690
+ auto_search: true,
2691
+ };
2692
+ })(),
2693
+ extra: {
2694
+ meta: {
2695
+ subChatType: "t2t",
2696
+ },
2697
+ },
2698
+ sub_chat_type: "t2t",
2699
+ parent_id: actualParentId,
2700
+ },
2701
+ ],
2702
+ timestamp: timestamp + 1,
2703
+ };
2704
+
2705
+ // Debug-only: textSize hashes/scans the whole prompt and the preview runs a
2706
+ // full-string regex, so build the payload only when it will actually log.
2707
+ if (logger.isLevelEnabled("debug")) {
2708
+ logger.debug("[Qwen] chat payload", {
2709
+ accountId: accountId ?? "global",
2710
+ model,
2711
+ chatId: chatSessionId || "new",
2712
+ parentId: actualParentId || null,
2713
+ content: textSize(prompt),
2714
+ preview: prompt.replace(/\s+/g, " ").trim().slice(0, 160),
2715
+ });
2716
+ }
2717
+
2718
+ // Dynamic timeout based on payload size
2719
+ const BASE_TIMEOUT_MS = 120000;
2720
+ const TIMEOUT_PER_MB = 30000;
2721
+
2722
+ const payloadJson = JSON.stringify(payload);
2723
+ const payloadSize = Buffer.byteLength(payloadJson);
2724
+ const tokenEstimationContext: TokenEstimationContext = {
2725
+ activePersonalization: getActivePersonalizationInfo(accountId ?? "global"),
2726
+ qwenPayloadBytes: payloadSize,
2727
+ qwenPayloadPromptChars: prompt.length,
2728
+ qwenPayloadMessageCount: payload.messages.length,
2729
+ };
2730
+
2731
+ if (payloadSize > MAX_PAYLOAD_SIZE) {
2732
+ throw new Error(
2733
+ `Payload too large: ${payloadSize} bytes exceeds limit of ${MAX_PAYLOAD_SIZE} bytes`,
2734
+ );
2735
+ }
2736
+
2737
+ const payloadMB = payloadSize / (1024 * 1024);
2738
+ const dynamicTimeoutMs = enableThinking
2739
+ ? Math.max(
2740
+ config.timeouts.reasoningModelTimeout,
2741
+ BASE_TIMEOUT_MS + Math.ceil(payloadMB * TIMEOUT_PER_MB),
2742
+ )
2743
+ : BASE_TIMEOUT_MS + Math.ceil(payloadMB * TIMEOUT_PER_MB);
2744
+ // Keep the total generation budget separate from the browser bridge startup
2745
+ // and first-response-header deadlines. The bridge releases the account page
2746
+ // mutex immediately; this budget only bounds the request lifecycle.
2747
+ const browserStreamBudgetMs = Math.max(
2748
+ config.timeouts.page,
2749
+ Math.min(
2750
+ dynamicTimeoutMs,
2751
+ config.timeouts.totalRequestTimeout > 0
2752
+ ? config.timeouts.totalRequestTimeout
2753
+ : dynamicTimeoutMs,
2754
+ ),
2755
+ );
2756
+
2757
+ const url = chatSessionId
2758
+ ? qwenUrl(`/api/v2/chat/completions?chat_id=${encodeURIComponent(chatSessionId)}`)
2759
+ : qwenUrl("/api/v2/chat/completions");
2760
+
2761
+ const controller = new AbortController();
2762
+ const timeoutId = setTimeout(() => controller.abort(), dynamicTimeoutMs);
2763
+ // Propagate client/supersede aborts to the upstream fetch IMMEDIATELY: the
2764
+ // internal controller is otherwise only aborted by the dynamic timeout, so a
2765
+ // superseded generation would keep running — and keep holding the chat lock
2766
+ // — until the idle timeout (180s+) instead of dying instantly.
2767
+ const onExternalAbort = () => controller.abort();
2768
+ signal?.addEventListener("abort", onExternalAbort, { once: true });
2769
+
2770
+ try {
2771
+ const fetchCompletion = async (requestHeaders: Record<string, string>): Promise<Response> => {
2772
+ return createQwenBrowserResponse(
2773
+ accountId,
2774
+ url,
2775
+ "POST",
2776
+ // The 0.2.86 HAR shows the real client POSTs completions with
2777
+ // bx-ua/bx-umidtoken + x-accel-buffering, so the relay matches it
2778
+ // instead of relying on sendBxUa.
2779
+ buildCompletionHeaders(requestHeaders, chatSessionId),
2780
+ payloadJson,
2781
+ controller.signal,
2782
+ qwenUrl(
2783
+ chatSessionId
2784
+ ? `/c/${encodeURIComponent(chatSessionId)}`
2785
+ : "/",
2786
+ ),
2787
+ browserStreamBudgetMs,
2788
+ );
2789
+ };
2790
+
2791
+ let response!: Response;
2792
+ let captchaRecoveryAttempted = false;
2793
+ const retryAfterCaptchaRecovery = async (
2794
+ label: string,
2795
+ challengeBody: string,
2796
+ ): Promise<boolean> => {
2797
+ if (captchaRecoveryAttempted || !accountId) return false;
2798
+ captchaRecoveryAttempted = true;
2799
+
2800
+ const solved = await recoverBaxiaCaptcha(accountId, label, {
2801
+ challengeBody,
2802
+ });
2803
+ if (!solved) return false;
2804
+
2805
+ // The challenge may have rotated bx-* values or session cookies. Refresh
2806
+ // them only after the visible challenge was solved, then replay the same
2807
+ // payload on the same account.
2808
+ const refreshed = await getQwenHeaders(true, accountId);
2809
+ activeHeaders = refreshed.headers;
2810
+ if (config.captcha.retryDelayMs > 0) {
2811
+ await sleep(config.captcha.retryDelayMs);
2812
+ }
2813
+ ensureNotAborted();
2814
+ response = await fetchCompletion(activeHeaders);
2815
+ return true;
2816
+ };
2817
+
2818
+ let captchaMetadataRetryAttempted = false;
2819
+ const throwFetchCompletionError = (error: unknown): never => {
2820
+ const errorMsg = error instanceof Error ? error.message : String(error);
2821
+ // Treat network errors (fetch failed, timeout, DNS, first-byte stall,
2822
+ // etc.) as retryable so account rotation kicks in instead of a terminal
2823
+ // 500 that forces the client to retry in a loop ("request hangs").
2824
+ if (isRetryableFetchErrorMessage(errorMsg) || error instanceof TypeError) {
2825
+ throw withCreatedChatMetadata(new QwenNetworkError(errorMsg));
2826
+ }
2827
+ throw withCreatedChatMetadata(
2828
+ error instanceof Error ? error : new Error(errorMsg),
2829
+ );
2830
+ };
2831
+
2832
+ try {
2833
+ ensureNotAborted();
2834
+ phase("fetch");
2835
+ response = await fetchCompletion(activeHeaders);
2836
+ } catch (error) {
2837
+ // The challenge was solved while waiting for headers, but the original
2838
+ // background fetch did not resume. Replay the same payload on the same
2839
+ // account with fresh headers instead of failing as an unknown error.
2840
+ if (
2841
+ (error as any)?.captchaSolvedDuringMetadata &&
2842
+ accountId &&
2843
+ !captchaMetadataRetryAttempted
2844
+ ) {
2845
+ captchaMetadataRetryAttempted = true;
2846
+ logger.warn(
2847
+ "[Qwen] Completion headers timed out after captcha recovery; retrying with fresh headers",
2848
+ {
2849
+ accountId,
2850
+ chatId: chatSessionId ?? "new",
2851
+ },
2852
+ );
2853
+ const refreshed = await getQwenHeaders(true, accountId);
2854
+ activeHeaders = refreshed.headers;
2855
+ if (config.captcha.retryDelayMs > 0) {
2856
+ await sleep(config.captcha.retryDelayMs);
2857
+ }
2858
+ try {
2859
+ ensureNotAborted();
2860
+ response = await fetchCompletion(activeHeaders);
2861
+ } catch (retryError) {
2862
+ throwFetchCompletionError(retryError);
2863
+ }
2864
+ } else {
2865
+ throwFetchCompletionError(error);
2866
+ }
2867
+ }
2868
+
2869
+ let responseContentType = response.headers.get("content-type") || "";
2870
+ let retriedNonSseResponse = false;
2871
+
2872
+ while (true) {
2873
+ responseContentType = response.headers.get("content-type") || "";
2874
+
2875
+ const isNonSseSuccessResponse =
2876
+ response.ok &&
2877
+ responseContentType.trim().length > 0 &&
2878
+ !responseContentType.includes("text/event-stream") &&
2879
+ !responseContentType.includes("application/json") &&
2880
+ Boolean(response.body);
2881
+
2882
+ if (
2883
+ isHtmlResponseContentType(responseContentType) ||
2884
+ isNonSseSuccessResponse
2885
+ ) {
2886
+ const preview = await readResponsePreview(response);
2887
+ const htmlBody = isHtmlResponseBody(preview);
2888
+ const antiBotChallenge = isWafChallengeResponse(preview);
2889
+ logger.warn(
2890
+ htmlBody || isHtmlResponseContentType(responseContentType)
2891
+ ? "[Qwen] Completion returned HTML instead of SSE"
2892
+ : "[Qwen] Completion returned a non-SSE body",
2893
+ {
2894
+ accountId: accountId ?? "global",
2895
+ chatId: chatSessionId ?? "new",
2896
+ status: response.status,
2897
+ contentType: responseContentType,
2898
+ antiBotChallenge,
2899
+ previewBytes: Buffer.byteLength(preview, "utf8"),
2900
+ },
2901
+ );
2902
+
2903
+ if (
2904
+ antiBotChallenge &&
2905
+ (await retryAfterCaptchaRecovery(
2906
+ `chat ${chatSessionId ?? "new"}`,
2907
+ preview,
2908
+ ))
2909
+ ) {
2910
+ retriedNonSseResponse = true;
2911
+ continue;
2912
+ }
2913
+
2914
+ if (!antiBotChallenge && !retriedNonSseResponse) {
2915
+ retriedNonSseResponse = true;
2916
+ const refreshed = await getQwenHeaders(true, accountId);
2917
+ activeHeaders = refreshed.headers;
2918
+ response = await fetchCompletion(activeHeaders);
2919
+ continue;
2920
+ }
2921
+
2922
+ throw withCreatedChatMetadata(
2923
+ new QwenUpstreamError(
2924
+ antiBotChallenge
2925
+ ? "Qwen returned an anti-bot challenge instead of an SSE response."
2926
+ : "Qwen returned an HTML response instead of an SSE response.",
2927
+ antiBotChallenge
2928
+ ? "waf_challenge"
2929
+ : htmlBody || isHtmlResponseContentType(responseContentType)
2930
+ ? "non_sse_html_response"
2931
+ : "non_sse_response",
2932
+ 502,
2933
+ ),
2934
+ );
2935
+ }
2936
+
2937
+ if (
2938
+ response.status === 200 &&
2939
+ !responseContentType.includes("text/event-stream") &&
2940
+ (!response.body || response.headers.get("content-length") === "0")
2941
+ ) {
2942
+ if (!retriedNonSseResponse) {
2943
+ logger.warn(
2944
+ "[Qwen] Completion returned an empty non-stream 200 response; retrying with fresh headers.",
2945
+ {
2946
+ accountId: accountId ?? "global",
2947
+ chatId: chatSessionId ?? "new",
2948
+ contentType: responseContentType || null,
2949
+ },
2950
+ );
2951
+ retriedNonSseResponse = true;
2952
+ const refreshed = await getQwenHeaders(true, accountId);
2953
+ activeHeaders = refreshed.headers;
2954
+ response = await fetchCompletion(activeHeaders);
2955
+ continue;
2956
+ }
2957
+ break;
2958
+ }
2959
+
2960
+ if (response.ok && responseContentType.includes("application/json")) {
2961
+ const errText = await response.text().catch(() => "");
2962
+
2963
+ const htmlResponse = isHtmlResponseBody(errText);
2964
+ const antiBotChallenge = isWafChallengeResponse(errText);
2965
+ if (antiBotChallenge || htmlResponse) {
2966
+ logger.warn(
2967
+ "[Qwen] Completion returned an HTML or anti-bot challenge body instead of SSE.",
2968
+ {
2969
+ accountId: accountId ?? "global",
2970
+ chatId: chatSessionId ?? "new",
2971
+ antiBotChallenge,
2972
+ previewBytes: Buffer.byteLength(errText, "utf8"),
2973
+ },
2974
+ );
2975
+
2976
+ if (
2977
+ antiBotChallenge &&
2978
+ (await retryAfterCaptchaRecovery(
2979
+ `chat ${chatSessionId ?? "new"}`,
2980
+ errText,
2981
+ ))
2982
+ ) {
2983
+ retriedNonSseResponse = true;
2984
+ continue;
2985
+ }
2986
+
2987
+ if (!antiBotChallenge && !retriedNonSseResponse) {
2988
+ retriedNonSseResponse = true;
2989
+ const refreshed = await getQwenHeaders(true, accountId);
2990
+ activeHeaders = refreshed.headers;
2991
+ response = await fetchCompletion(activeHeaders);
2992
+ continue;
2993
+ }
2994
+
2995
+ throw withCreatedChatMetadata(
2996
+ new QwenUpstreamError(
2997
+ antiBotChallenge
2998
+ ? "Qwen returned an anti-bot challenge instead of an SSE response."
2999
+ : "Qwen returned an HTML response instead of an SSE response.",
3000
+ antiBotChallenge ? "waf_challenge" : "non_sse_html_response",
3001
+ 502,
3002
+ ),
3003
+ );
3004
+ }
3005
+
3006
+ throw withCreatedChatMetadata(
3007
+ parseQwenJsonError(errText, response.status, accountId) ??
3008
+ new QwenUpstreamError(
3009
+ `Qwen returned non-stream JSON response: ${errText.substring(0, 300)}`,
3010
+ "NonStreamJsonResponse",
3011
+ 502,
3012
+ ),
3013
+ );
3014
+ }
3015
+
3016
+ break;
3017
+ }
3018
+
3019
+ phase("metadata");
3020
+
3021
+ if (logger.isLevelEnabled("info")) {
3022
+ // What the upstream actually received: new vs reused chat, warm-pool
3023
+ // lease, payload weight, parent chain. The 📤 line shows the client view;
3024
+ // this is the upstream-facing counterpart.
3025
+ console.log(
3026
+ `⏱️ [Qwen] Create: ready | account=${accountId ?? "global"} | chat=${(chatSessionId ?? "new").substring(0, 12)} | ${createdNewChat ? "new-chat" : "reuse"}${leasedWarmChat ? " | warm-pool" : ""} | payload=${payloadSize}B | parent=${actualParentId ? actualParentId.substring(0, 8) : "none"} | +${Date.now() - startedAt}ms`,
3027
+ );
3028
+ }
3029
+
3030
+ if (!response.ok || !response.body) {
3031
+ const contentType = response.headers.get("content-type") || "";
3032
+ const errText = contentType.includes("application/json")
3033
+ ? await response.text().catch(() => "")
3034
+ : await readResponsePreview(response);
3035
+ const antiBotChallenge = isWafChallengeResponse(errText);
3036
+
3037
+ if (
3038
+ antiBotChallenge &&
3039
+ (await retryAfterCaptchaRecovery(
3040
+ `chat ${chatSessionId ?? "new"}`,
3041
+ errText,
3042
+ ))
3043
+ ) {
3044
+ const recoveredContentType = response.headers.get("content-type") || "";
3045
+ if (
3046
+ response.ok &&
3047
+ response.body &&
3048
+ recoveredContentType.includes("text/event-stream")
3049
+ ) {
3050
+ return {
3051
+ stream: wrapUpstreamStream(response.body, controller),
3052
+ headers: activeHeaders,
3053
+ uiSessionId: chatSessionId || "",
3054
+ controller,
3055
+ accountId: accountId ?? "global",
3056
+ createdNewChat,
3057
+ tokenEstimationContext,
3058
+ };
3059
+ }
3060
+ }
3061
+
3062
+ // Handle 502/503/504 as retryable upstream unavailability
3063
+ if (
3064
+ response.status === 502 ||
3065
+ response.status === 503 ||
3066
+ response.status === 504
3067
+ ) {
3068
+ throw withCreatedChatMetadata(
3069
+ new QwenUpstreamUnavailableError(
3070
+ `Qwen upstream unavailable: ${response.status} ${response.statusText}`,
3071
+ response.status,
3072
+ ),
3073
+ );
3074
+ }
3075
+
3076
+ if (contentType.includes("application/json")) {
3077
+ try {
3078
+ const parsedError = parseQwenJsonError(
3079
+ errText,
3080
+ response.status,
3081
+ accountId,
3082
+ );
3083
+ if (parsedError) {
3084
+ throw withCreatedChatMetadata(parsedError);
3085
+ }
3086
+ } catch (parseOrRetryError) {
3087
+ if (
3088
+ parseOrRetryError instanceof RetryableQwenStreamError ||
3089
+ parseOrRetryError instanceof QwenUpstreamError ||
3090
+ parseOrRetryError instanceof QwenSessionExpiredError
3091
+ ) {
3092
+ throw withCreatedChatMetadata(parseOrRetryError);
3093
+ }
3094
+ logger.warn("Unexpected error during stream error parsing", {
3095
+ error: parseOrRetryError,
3096
+ });
3097
+ }
3098
+ }
3099
+ throw withCreatedChatMetadata(
3100
+ new QwenUpstreamError(
3101
+ `Qwen completion request failed: ${response.status} ${response.statusText}`,
3102
+ isWafChallengeResponse(errText)
3103
+ ? "waf_challenge"
3104
+ : "completion_http_error",
3105
+ 502,
3106
+ ),
3107
+ );
3108
+ }
3109
+
3110
+ return {
3111
+ stream: wrapUpstreamStream(response.body, controller),
3112
+ headers: activeHeaders,
3113
+ uiSessionId: chatSessionId || "",
3114
+ controller,
3115
+ accountId: accountId ?? "global",
3116
+ createdNewChat,
3117
+ tokenEstimationContext,
3118
+ };
3119
+ } catch (error) {
3120
+ releaseStreamResources();
3121
+ throw error;
3122
+ } finally {
3123
+ signal?.removeEventListener("abort", onExternalAbort);
3124
+ clearTimeout(timeoutId);
3125
+ }
3126
+ }