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,1748 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import { getQwenHeaders, isAuthMockEnabled } from "./auth-playwright.ts";
3
+ import { buildQwenRequestHeaders } from "./qwen-headers.ts";
4
+ import { qwenUrl } from "./qwen-url.ts";
5
+
6
+ import { config } from "../core/config.ts";
7
+ import {
8
+ getNextAvailableAccount,
9
+ markAccountRateLimited,
10
+ clearAccountCooldown,
11
+ } from "../core/account-manager.ts";
12
+ import { UpstreamError, AuthError, UpstreamRateLimit } from "../core/errors.ts";
13
+ import { isAntiBotError } from "../routes/chat/retry-policy.ts";
14
+ import { recoverBaxiaCaptcha } from "./captcha-coordinator.ts";
15
+ import { startBaxiaCaptchaWatcher } from "./captcha-solver.ts";
16
+ import { withAccountPage } from "./playwright.ts";
17
+
18
+ /**
19
+ * Heuristic for a WAF/captcha challenge page body returned by Qwen instead of JSON.
20
+ * Kept local so media generation does not depend on a solver-specific helper.
21
+ */
22
+ export function looksLikeAntiBotChallengeText(text: string): boolean {
23
+ const lower = String(text || "").toLowerCase();
24
+ return (
25
+ lower.includes("fail_sys_user_validate") ||
26
+ lower.includes("rgv587_error") ||
27
+ lower.includes("_____tmd_____") ||
28
+ lower.includes("x5secdata") ||
29
+ lower.includes("punish") ||
30
+ lower.includes("nocaptcha") ||
31
+ lower.includes("captcha") ||
32
+ lower.includes("aliyuncaptcha") ||
33
+ lower.includes("baxia") ||
34
+ lower.includes("access verification") ||
35
+ lower.includes("security verification") ||
36
+ lower.includes("verify you are human") ||
37
+ lower.includes("human verification") ||
38
+ lower.includes("denyfromx5")
39
+ );
40
+ }
41
+
42
+ export interface ImageGenerationResult {
43
+ url: string;
44
+ revised_prompt?: string;
45
+ width?: number;
46
+ height?: number;
47
+ accountId: string;
48
+ chatId: string;
49
+ }
50
+
51
+ export interface VideoGenerationResult {
52
+ task_id: string;
53
+ status: "pending" | "running" | "completed" | "failed";
54
+ video_url?: string;
55
+ accountId: string;
56
+ chatId: string;
57
+ }
58
+
59
+ export interface VideoTaskStatus {
60
+ status: "pending" | "running" | "completed" | "failed";
61
+ video_url?: string;
62
+ error?: string;
63
+ }
64
+
65
+ const IMAGE_TIMEOUT_MS = 120_000;
66
+ const VIDEO_TIMEOUT_MS = 300_000;
67
+ const MAX_ACCOUNT_ATTEMPTS = 3;
68
+ const ACCOUNT_COOLDOWN_MS = 60_000;
69
+ const VIDEO_POLL_INTERVAL_MS = 5_000;
70
+
71
+ /**
72
+ * Node attempts for the completions request: 1 initial + 2 retries. Aliyun WAF
73
+ * is intermittent — FreeQwenApi's transport.js retries with a short delay
74
+ * before falling back to the browser session, and a retry often passes clean.
75
+ */
76
+ const NODE_COMPLETION_ATTEMPTS = 3;
77
+ const NODE_COMPLETION_RETRY_DELAY_MS = 1_000;
78
+
79
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
80
+
81
+ export type MediaKind = "image" | "video";
82
+
83
+ /** Keep media logs compact and prevent signed CDN URLs from leaking into logs. */
84
+ function sanitizeMediaLogValue(value: unknown): string {
85
+ const text = String(value)
86
+ .replace(/https?:\/\/\S+/gi, "[redacted-url]")
87
+ .replace(/[\r\n]+/g, " ");
88
+ return text.length > 240 ? `${text.slice(0, 240)}…` : text;
89
+ }
90
+
91
+ export function shortMediaId(value: string, length = 12): string {
92
+ return value.length > length ? value.slice(0, length) : value;
93
+ }
94
+
95
+ export function mediaLog(
96
+ kind: MediaKind,
97
+ event: string,
98
+ fields: Record<string, string | number | boolean | undefined> = {},
99
+ ): string {
100
+ const icon = kind === "image" ? "🎨" : "🎬";
101
+ const details = Object.entries(fields)
102
+ .filter(([, value]) => value !== undefined)
103
+ .map(([key, value]) => `${key}=${sanitizeMediaLogValue(value)}`)
104
+ .join(" | ");
105
+ return `${icon} [Media] ${event}${details ? ` | ${details}` : ""}`;
106
+ }
107
+
108
+ export function logMediaInfo(message: string): void {
109
+ console.log(message);
110
+ }
111
+
112
+ export function logMediaDebug(message: string): void {
113
+ if (process.env.LOG_LEVEL === "debug") {
114
+ console.log(`🔍 ${message}`);
115
+ }
116
+ }
117
+
118
+ export function logMediaWarn(message: string): void {
119
+ console.warn(`⚠️ ${message}`);
120
+ }
121
+
122
+ export function logMediaError(message: string): void {
123
+ console.error(`❌ ${message}`);
124
+ }
125
+
126
+ /**
127
+ * Chat model used for image/video generation via Qwen Chat.
128
+ * Based on real Qwen traffic: qwen3.8-max is used as the chat model, and
129
+ * the generation-specific models (qwen-image-*, wan2.*) are passed separately
130
+ * in the payload, never as the chat model itself.
131
+ */
132
+ export const CHAT_MEDIA_MODEL = "qwen3.8-max";
133
+
134
+ /** Models that are generation-specific (not chat models). */
135
+ export type MediaGenerationMode = "t2i" | "i2i" | "t2v" | "i2v";
136
+
137
+ type MediaModelDefinition = {
138
+ id: string;
139
+ kind: "image" | "video";
140
+ modes: readonly MediaGenerationMode[];
141
+ };
142
+
143
+ const MEDIA_MODEL_DEFINITIONS: readonly MediaModelDefinition[] = [
144
+ // Text-to-image & image-editing models (current 3.0, 2.7 and turbo generation).
145
+ { id: "qwen-image-3.0-pro", kind: "image", modes: ["t2i", "i2i"] },
146
+ { id: "qwen-image-3.0", kind: "image", modes: ["t2i", "i2i"] },
147
+ { id: "wan2.7-image-pro", kind: "image", modes: ["t2i", "i2i"] },
148
+ { id: "wan2.7-image", kind: "image", modes: ["t2i", "i2i"] },
149
+ { id: "z-image-turbo", kind: "image", modes: ["t2i"] },
150
+
151
+ // Video generation models (current 3.0 and 2.7 generation).
152
+ { id: "wan3.0-video", kind: "video", modes: ["t2v", "i2v"] },
153
+ { id: "wan2.7-t2v", kind: "video", modes: ["t2v"] },
154
+ { id: "wan2.7-i2v", kind: "video", modes: ["i2v"] },
155
+ ];
156
+ const MEDIA_IMAGE_MODELS = MEDIA_MODEL_DEFINITIONS.filter(
157
+ ({ kind }) => kind === "image",
158
+ ).map(({ id }) => id);
159
+ const MEDIA_VIDEO_MODELS = MEDIA_MODEL_DEFINITIONS.filter(
160
+ ({ kind }) => kind === "video",
161
+ ).map(({ id }) => id);
162
+ const MEDIA_GENERATION_MODELS = new Set(
163
+ MEDIA_MODEL_DEFINITIONS.map(({ id }) => id),
164
+ );
165
+
166
+ /** Shared media sizes accepted by image/video endpoints and chat completions. */
167
+ export const MEDIA_SIZE_OPTIONS = [
168
+ "auto",
169
+ "1:1",
170
+ "3:4",
171
+ "4:3",
172
+ "16:9",
173
+ "9:16",
174
+ "1024x1024",
175
+ "1792x1024",
176
+ "1024x1792",
177
+ ] as const;
178
+
179
+ export function isSupportedMediaSize(
180
+ value: unknown,
181
+ ): value is (typeof MEDIA_SIZE_OPTIONS)[number] {
182
+ return (
183
+ typeof value === "string" &&
184
+ (MEDIA_SIZE_OPTIONS as readonly string[]).includes(value)
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Public media model catalog so `/v1/models` can advertise image/video
190
+ * generation models alongside the live Qwen chat catalog.
191
+ */
192
+ export function listMediaGenerationModels(): Array<{
193
+ id: string;
194
+ kind: "image" | "video";
195
+ modes: readonly MediaGenerationMode[];
196
+ }> {
197
+ return MEDIA_MODEL_DEFINITIONS.map((definition) => ({
198
+ id: definition.id,
199
+ kind: definition.kind,
200
+ modes: definition.modes,
201
+ }));
202
+ }
203
+
204
+ export function getMediaModelModes(
205
+ model?: string | null,
206
+ ): readonly MediaGenerationMode[] | null {
207
+ const id = model?.trim();
208
+ if (!id) return null;
209
+ return (
210
+ MEDIA_MODEL_DEFINITIONS.find((definition) => definition.id === id)?.modes ??
211
+ null
212
+ );
213
+ }
214
+
215
+ export function supportsPromptMediaGeneration(
216
+ model: string,
217
+ kind: "image" | "video",
218
+ ): boolean {
219
+ const modes = getMediaModelModes(model);
220
+ if (!modes) return true;
221
+ return modes.includes(kind === "image" ? "t2i" : "t2v");
222
+ }
223
+
224
+ /**
225
+ * Resolves the generation model. When the client requests a generation-specific
226
+ * model (qwen-image-*, wan2.*), the chat model stays CHAT_MEDIA_MODEL and the
227
+ * requested model is passed separately. Any other model (e.g. qwen3-vl-plus,
228
+ * qwen-max-latest) is used directly as the chat model.
229
+ */
230
+ export function resolveMediaModel(
231
+ requestedModel?: string,
232
+ ): { chatModel: string; generationModel?: string } {
233
+ const explicitModel = requestedModel?.trim();
234
+ if (!explicitModel) {
235
+ throw new UpstreamError(
236
+ "A model selected by the client is required for image/video generation",
237
+ );
238
+ }
239
+ if (MEDIA_GENERATION_MODELS.has(explicitModel)) {
240
+ return { chatModel: CHAT_MEDIA_MODEL, generationModel: explicitModel };
241
+ }
242
+ return { chatModel: explicitModel, generationModel: undefined };
243
+ }
244
+
245
+ /**
246
+ * Classifies a client-selected model as a media generation model. Returns
247
+ * "image"/"video" for generation-specific models, or null for regular chat
248
+ * models. Chat completions uses this to route image/video requests to the
249
+ * native generation pipeline instead of the text flow.
250
+ */
251
+ export function classifyMediaModel(
252
+ model?: string | null,
253
+ ): "image" | "video" | null {
254
+ const m = model?.trim();
255
+ if (!m) return null;
256
+ if ((MEDIA_IMAGE_MODELS as readonly string[]).includes(m)) return "image";
257
+ if ((MEDIA_VIDEO_MODELS as readonly string[]).includes(m)) return "video";
258
+ return null;
259
+ }
260
+
261
+ function normalizeSize(size?: string): string | undefined {
262
+ if (!size) return undefined;
263
+ // "auto" lets Qwen pick the aspect ratio (seen in real t2i traffic).
264
+ if (size === "auto") return "auto";
265
+ if (/^\d+:\d+$/.test(size)) return size;
266
+ const match = size.match(/^(\d+)x(\d+)$/);
267
+ if (match) {
268
+ const w = parseInt(match[1]);
269
+ const h = parseInt(match[2]);
270
+ if (w === h) return "1:1";
271
+ if (w > h) return "16:9";
272
+ return "9:16";
273
+ }
274
+ return size;
275
+ }
276
+
277
+ /**
278
+ * The Qwen webapp authenticates API calls with `Authorization: Bearer <token>`
279
+ * — the JWT that is also present in the session cookie / localStorage. Both the
280
+ * Node and the browser paths in FreeQwenApi's working transport always send
281
+ * it; requests without it are far more likely to be challenged by the WAF.
282
+ */
283
+ function extractBearerToken(cookie: string | undefined): string | null {
284
+ if (!cookie) return null;
285
+ const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
286
+ if (!match) return null;
287
+ try {
288
+ return decodeURIComponent(match[1]);
289
+ } catch {
290
+ return match[1];
291
+ }
292
+ }
293
+
294
+ function buildHeadersFromCaptured(
295
+ headers: Record<string, string>,
296
+ chatSessionId?: string,
297
+ ): Record<string, string> {
298
+ const bearerToken = extractBearerToken(headers["cookie"] ?? headers["Cookie"]);
299
+ return buildQwenRequestHeaders({
300
+ cookie: headers["cookie"],
301
+ userAgent: headers["user-agent"],
302
+ bxUa: headers["bx-ua"],
303
+ bxUmidtoken: headers["bx-umidtoken"],
304
+ bxV: headers["bx-v"],
305
+ chatSessionId,
306
+ extra: {
307
+ Referer: chatSessionId
308
+ ? qwenUrl(`/c/${encodeURIComponent(chatSessionId)}`)
309
+ : qwenUrl("/"),
310
+ "x-accel-buffering": "no",
311
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
312
+ },
313
+ });
314
+ }
315
+
316
+ /** Headers a browser fetch() is not allowed to set (mirrors qwen.ts). */
317
+ const BROWSER_FORBIDDEN_HEADERS = new Set([
318
+ "accept-encoding",
319
+ "connection",
320
+ "content-length",
321
+ "cookie",
322
+ "host",
323
+ "origin",
324
+ "referer",
325
+ "user-agent",
326
+ ]);
327
+
328
+ function filterHeadersForBrowserFetch(
329
+ headers: Record<string, string>,
330
+ ): Record<string, string> {
331
+ return Object.fromEntries(
332
+ Object.entries(headers).filter(([name]) => {
333
+ const normalized = name.toLowerCase();
334
+ return (
335
+ !BROWSER_FORBIDDEN_HEADERS.has(normalized) &&
336
+ !normalized.startsWith("sec-")
337
+ );
338
+ }),
339
+ );
340
+ }
341
+
342
+ interface BrowserCompletionResponse {
343
+ status: number;
344
+ contentType: string;
345
+ rawBody: string;
346
+ /** Present when the in-page fetch itself failed (network/abort/timeout). */
347
+ error?: string;
348
+ }
349
+
350
+ /**
351
+ * Runs the completions request inside the account's live Playwright page.
352
+ * Adapted from FreeQwenApi's transport.js inPageRequest:
353
+ * - fetch with credentials so the signed session travels with the request;
354
+ * - SSE bodies are read with early-break on stream-finish signals because
355
+ * Qwen keeps the connection open after [DONE] / finish_reason — a plain
356
+ * response.text() would hang until the outer timeout;
357
+ * - a captcha watcher solves Baxia challenges that appear mid-request, the
358
+ * same mechanism the working chat flow uses.
359
+ */
360
+ async function requestCompletionsInBrowser(params: {
361
+ accountId: string;
362
+ url: string;
363
+ payloadJson: string;
364
+ headers: Record<string, string>;
365
+ referrer: string;
366
+ streaming: boolean;
367
+ timeoutMs: number;
368
+ }): Promise<BrowserCompletionResponse> {
369
+ const {
370
+ accountId,
371
+ url,
372
+ payloadJson,
373
+ headers,
374
+ referrer,
375
+ streaming,
376
+ timeoutMs,
377
+ } = params;
378
+
379
+ const browserHeaders = filterHeadersForBrowserFetch(headers);
380
+ if (
381
+ !Object.keys(browserHeaders).some(
382
+ (name) => name.toLowerCase() === "content-type",
383
+ )
384
+ ) {
385
+ browserHeaders["Content-Type"] = "application/json";
386
+ }
387
+
388
+ // The in-page deadline must fire before the page-operation timeout so a
389
+ // stuck fetch resolves gracefully instead of resetting the account context.
390
+ const fetchTimeoutMs = Math.max(10_000, timeoutMs - 8_000);
391
+
392
+ return withAccountPage(
393
+ accountId,
394
+ async (page) => {
395
+ // Keep the page on the chat UI so the same-origin fetch carries the
396
+ // live session (mirrors withQwenBrowserPage in qwen.ts).
397
+ const targetUrl = qwenUrl("/c/new-chat");
398
+ let needsNavigation = true;
399
+ try {
400
+ const current = new URL(page.url());
401
+ const target = new URL(targetUrl);
402
+ needsNavigation =
403
+ current.origin !== target.origin ||
404
+ current.pathname.replace(/\/+$/, "") !== "/c/new-chat";
405
+ } catch {
406
+ needsNavigation = true;
407
+ }
408
+ if (needsNavigation) {
409
+ await page.goto(targetUrl, {
410
+ waitUntil: "domcontentloaded",
411
+ timeout: Math.min(config.timeouts.navigation, timeoutMs),
412
+ });
413
+ }
414
+
415
+ let captchaWatcher:
416
+ | ReturnType<typeof startBaxiaCaptchaWatcher>
417
+ | undefined;
418
+ if (config.captcha.enabled) {
419
+ captchaWatcher = startBaxiaCaptchaWatcher(page, timeoutMs, {
420
+ maxAttempts: config.captcha.maxAttempts,
421
+ retryDelayMs: config.captcha.retryDelayMs,
422
+ settleMs: config.captcha.settleMs,
423
+ });
424
+ }
425
+
426
+ try {
427
+ return await page.evaluate(
428
+ async ({
429
+ url,
430
+ headers,
431
+ body,
432
+ referrer,
433
+ streaming,
434
+ fetchTimeoutMs,
435
+ }: {
436
+ url: string;
437
+ headers: Record<string, string>;
438
+ body: string;
439
+ referrer: string;
440
+ streaming: boolean;
441
+ fetchTimeoutMs: number;
442
+ }): Promise<{
443
+ status: number;
444
+ contentType: string;
445
+ rawBody: string;
446
+ error?: string;
447
+ }> => {
448
+ const controller = new AbortController();
449
+ const timeoutId = setTimeout(
450
+ () => controller.abort(),
451
+ fetchTimeoutMs,
452
+ );
453
+ try {
454
+ const response = await fetch(url, {
455
+ method: "POST",
456
+ credentials: "include",
457
+ headers,
458
+ body,
459
+ signal: controller.signal,
460
+ referrer,
461
+ });
462
+ clearTimeout(timeoutId);
463
+
464
+ const contentType = response.headers.get("content-type") || "";
465
+
466
+ if (!response.ok) {
467
+ return {
468
+ status: response.status,
469
+ contentType,
470
+ rawBody: await response.text().catch(() => ""),
471
+ };
472
+ }
473
+
474
+ if (
475
+ !streaming ||
476
+ !contentType.includes("text/event-stream") ||
477
+ !response.body
478
+ ) {
479
+ return {
480
+ status: response.status,
481
+ contentType,
482
+ rawBody: await response.text(),
483
+ };
484
+ }
485
+
486
+ // Qwen keeps the SSE connection open after [DONE] /
487
+ // finish_reason, so break on stream-finish signals — otherwise
488
+ // this loop never ends (FreeQwenApi transport.js).
489
+ const reader = response.body.getReader();
490
+ const decoder = new TextDecoder();
491
+ let buffer = "";
492
+ let full = "";
493
+ let finished = false;
494
+
495
+ while (!finished) {
496
+ const { done, value } = await reader.read();
497
+ if (done) break;
498
+ const text = decoder.decode(value, { stream: true });
499
+ full += text;
500
+ buffer += text;
501
+
502
+ const lines = buffer.split("\n");
503
+ buffer = lines.pop() || "";
504
+ for (const line of lines) {
505
+ const trimmed = line.trim();
506
+ if (trimmed === "data: [DONE]") {
507
+ finished = true;
508
+ break;
509
+ }
510
+ if (!trimmed.startsWith("data:")) continue;
511
+ try {
512
+ const chunk = JSON.parse(trimmed.slice(5).trim());
513
+ const choice = chunk?.choices?.[0];
514
+ const delta = choice?.delta;
515
+ const phase = delta?.phase;
516
+ const isAnswerPhase = !phase || phase === "answer";
517
+ // status:"finished" ends a phase, not the stream — the
518
+ // thinking phase finishes before the answer phase starts.
519
+ if (
520
+ choice?.finish_reason ||
521
+ (delta?.status === "finished" && isAnswerPhase)
522
+ ) {
523
+ finished = true;
524
+ break;
525
+ }
526
+ } catch {
527
+ // Not JSON — keep reading.
528
+ }
529
+ }
530
+ }
531
+
532
+ await reader.cancel().catch(() => undefined);
533
+ return { status: response.status, contentType, rawBody: full };
534
+ } catch (error) {
535
+ clearTimeout(timeoutId);
536
+ return {
537
+ status: 0,
538
+ contentType: "",
539
+ rawBody: "",
540
+ error: error instanceof Error ? error.message : String(error),
541
+ };
542
+ }
543
+ },
544
+ {
545
+ url,
546
+ headers: browserHeaders,
547
+ body: payloadJson,
548
+ referrer,
549
+ streaming,
550
+ fetchTimeoutMs,
551
+ },
552
+ );
553
+ } finally {
554
+ captchaWatcher?.stop();
555
+ }
556
+ },
557
+ timeoutMs,
558
+ );
559
+ }
560
+
561
+ /**
562
+ * Runs the completions request Node-first, like FreeQwenApi's transport.js:
563
+ * Node fetch is fast but Aliyun WAF intermittently answers with a captcha
564
+ * (hence the short retries), while the fetch executed inside the live
565
+ * Playwright page carries the signed session. When both paths fail the error
566
+ * is classified: anti-bot failures carry an upstream code the media loops
567
+ * recognize so captcha recovery can run (the chat flow behaves the same way).
568
+ */
569
+ async function requestCompletionsWithBrowserFallback(params: {
570
+ kind: MediaKind;
571
+ accountId: string;
572
+ url: string;
573
+ payloadJson: string;
574
+ headers: Record<string, string>;
575
+ chatId: string;
576
+ streaming: boolean;
577
+ signal: AbortSignal;
578
+ timeoutMs: number;
579
+ }): Promise<{ status: number; rawBody: string }> {
580
+ const {
581
+ kind,
582
+ accountId,
583
+ url,
584
+ payloadJson,
585
+ headers,
586
+ chatId,
587
+ streaming,
588
+ signal,
589
+ timeoutMs,
590
+ } = params;
591
+
592
+ let sawAntiBotChallenge = false;
593
+ let lastFailureDetail = "";
594
+
595
+ if (!config.qwen.browserOnlyFetch) {
596
+ for (let attempt = 1; attempt <= NODE_COMPLETION_ATTEMPTS; attempt++) {
597
+ if (signal.aborted) break;
598
+ if (attempt > 1) {
599
+ await sleep(NODE_COMPLETION_RETRY_DELAY_MS);
600
+ }
601
+
602
+ try {
603
+ const nodeResponse = await fetch(url, {
604
+ method: "POST",
605
+ headers: {
606
+ ...headers,
607
+ Accept: "text/event-stream",
608
+ },
609
+ body: payloadJson,
610
+ signal,
611
+ });
612
+
613
+ const rawBody = await nodeResponse.text().catch(() => "");
614
+
615
+ if (nodeResponse.ok && !looksLikeAntiBotChallengeText(rawBody)) {
616
+ return { status: nodeResponse.status, rawBody };
617
+ }
618
+
619
+ if (looksLikeAntiBotChallengeText(rawBody)) {
620
+ sawAntiBotChallenge = true;
621
+ logMediaDebug(
622
+ mediaLog(kind, "transport_waf_blocked", {
623
+ account: shortMediaId(accountId),
624
+ attempt,
625
+ attempts: NODE_COMPLETION_ATTEMPTS,
626
+ }),
627
+ );
628
+ // WAF is intermittent — retry Node before the browser fallback.
629
+ continue;
630
+ }
631
+
632
+ // Conclusive upstream error — let the browser session try.
633
+ lastFailureDetail = `Node HTTP ${nodeResponse.status}: ${rawBody.substring(0, 200)}`;
634
+ break;
635
+ } catch (error) {
636
+ if (signal.aborted) break;
637
+ // Network failure — let the browser session try.
638
+ lastFailureDetail =
639
+ error instanceof Error ? error.message : String(error);
640
+ break;
641
+ }
642
+ }
643
+ }
644
+
645
+ if (!signal.aborted) {
646
+ logMediaInfo(
647
+ mediaLog(kind, "transport_fallback_started", {
648
+ account: shortMediaId(accountId),
649
+ transport: "browser",
650
+ }),
651
+ );
652
+
653
+ try {
654
+ const browserResult = await requestCompletionsInBrowser({
655
+ accountId,
656
+ url,
657
+ payloadJson,
658
+ headers,
659
+ referrer: qwenUrl(`/c/${encodeURIComponent(chatId)}`),
660
+ streaming,
661
+ timeoutMs: Math.min(timeoutMs, IMAGE_TIMEOUT_MS),
662
+ });
663
+
664
+ if (browserResult.error) {
665
+ lastFailureDetail = `Browser fetch: ${browserResult.error}`;
666
+ logMediaWarn(
667
+ mediaLog(kind, "transport_fallback_failed", {
668
+ account: shortMediaId(accountId),
669
+ transport: "browser",
670
+ error: browserResult.error,
671
+ }),
672
+ );
673
+ } else if (looksLikeAntiBotChallengeText(browserResult.rawBody)) {
674
+ sawAntiBotChallenge = true;
675
+ logMediaWarn(
676
+ mediaLog(kind, "transport_waf_blocked", {
677
+ account: shortMediaId(accountId),
678
+ transport: "browser",
679
+ }),
680
+ );
681
+ } else {
682
+ return { status: browserResult.status, rawBody: browserResult.rawBody };
683
+ }
684
+ } catch (error) {
685
+ const message = error instanceof Error ? error.message : String(error);
686
+ if (looksLikeAntiBotChallengeText(message)) {
687
+ sawAntiBotChallenge = true;
688
+ }
689
+ lastFailureDetail = `Browser fallback: ${message}`;
690
+ logMediaWarn(
691
+ mediaLog(kind, "transport_fallback_failed", {
692
+ account: shortMediaId(accountId),
693
+ transport: "browser",
694
+ error: message,
695
+ }),
696
+ );
697
+ }
698
+ }
699
+
700
+ if (signal.aborted) {
701
+ throw new DOMException("The operation was aborted", "AbortError");
702
+ }
703
+
704
+ // Classify anti-bot failures so the media loops trigger captcha recovery
705
+ // (isAntiBotError matches upstreamCode FAIL_SYS_USER_VALIDATE/RGV587_ERROR).
706
+ if (sawAntiBotChallenge) {
707
+ const error = new UpstreamError(
708
+ "Qwen anti-bot validation required: completions blocked by WAF (FAIL_SYS_USER_VALIDATE)",
709
+ ) as UpstreamError & { upstreamCode: string };
710
+ error.upstreamCode = "FAIL_SYS_USER_VALIDATE";
711
+ throw error;
712
+ }
713
+
714
+ throw new UpstreamError(
715
+ `Media generation completions failed in both Node and browser paths${
716
+ lastFailureDetail ? ` | ${lastFailureDetail}` : ""
717
+ }`,
718
+ );
719
+ }
720
+
721
+
722
+ async function createMediaChatSession(
723
+ headers: Record<string, string>,
724
+ chatModel: string,
725
+ chatType: "t2i" | "t2v",
726
+ signal: AbortSignal,
727
+ accountId?: string,
728
+ ): Promise<string> {
729
+ const title = chatType === "t2i" ? "Image Generation" : "Video Generation";
730
+
731
+ if (accountId && !isAuthMockEnabled()) {
732
+ return withAccountPage(
733
+ accountId,
734
+ async (page) => {
735
+ const result = await page.evaluate(
736
+ async ({ url, headers, body }) => {
737
+ try {
738
+ const response = await fetch(url, {
739
+ method: "POST",
740
+ credentials: "include",
741
+ headers,
742
+ body,
743
+ });
744
+ const rawText = await response.text().catch(() => "");
745
+ let data: any = null;
746
+ try {
747
+ data = JSON.parse(rawText);
748
+ } catch {}
749
+ return {
750
+ ok: response.ok,
751
+ status: response.status,
752
+ data,
753
+ rawText,
754
+ };
755
+ } catch (err: any) {
756
+ return {
757
+ ok: false,
758
+ status: 0,
759
+ data: null,
760
+ rawText: err?.message || String(err),
761
+ };
762
+ }
763
+ },
764
+ {
765
+ url: qwenUrl("/api/v2/chats/new"),
766
+ headers: buildHeadersFromCaptured(headers),
767
+ body: JSON.stringify({
768
+ title,
769
+ models: [chatModel],
770
+ chat_mode: "normal",
771
+ chat_type: chatType,
772
+ timestamp: Date.now(),
773
+ project_id: "",
774
+ }),
775
+ },
776
+ );
777
+ if (!result.ok) {
778
+ throw new UpstreamError(
779
+ `Failed to create ${chatType} chat session: ${result.status} ${String(result.rawText).substring(0, 200)}`,
780
+ );
781
+ }
782
+ const chatId = result.data?.data?.id || result.data?.data?.chat_id || result.data?.id;
783
+ if (!chatId) {
784
+ throw new UpstreamError(
785
+ `Upstream created ${chatType} chat without returning a chat ID`,
786
+ );
787
+ }
788
+ return chatId;
789
+ },
790
+ );
791
+ }
792
+
793
+ const response = await fetch(qwenUrl("/api/v2/chats/new"), {
794
+ method: "POST",
795
+ headers: buildHeadersFromCaptured(headers),
796
+ body: JSON.stringify({
797
+ title,
798
+ models: [chatModel],
799
+ chat_mode: "normal",
800
+ chat_type: chatType,
801
+ timestamp: Date.now(),
802
+ project_id: "",
803
+ }),
804
+ signal,
805
+ });
806
+
807
+ if (!response.ok) {
808
+ const text = await response.text().catch(() => "");
809
+ throw new UpstreamError(
810
+ `Failed to create ${chatType} chat session: ${response.status} ${text.substring(0, 200)}`,
811
+ );
812
+ }
813
+
814
+ const json = await response.json();
815
+ const chatId =
816
+ json?.chat_id ||
817
+ json?.id ||
818
+ json?.data?.chat_id ||
819
+ json?.data?.id ||
820
+ json?.data?.chat?.id;
821
+
822
+ if (!chatId || typeof chatId !== "string") {
823
+ throw new UpstreamError(
824
+ `Unexpected response when creating ${chatType} chat session`,
825
+ );
826
+ }
827
+
828
+ return chatId;
829
+ }
830
+
831
+ function buildCompletionsPayload(
832
+ chatId: string,
833
+ prompt: string,
834
+ chatModel: string,
835
+ chatType: "t2i" | "t2v",
836
+ size?: string,
837
+ generationModel?: string,
838
+ ): Record<string, unknown> {
839
+ const fid = uuidv4();
840
+ const childId = uuidv4();
841
+ const nowSec = Math.floor(Date.now() / 1000);
842
+
843
+ // Based on real Qwen traffic: media generation uses Fast thinking mode
844
+ // and does not need extended thinking
845
+ const userMessage: Record<string, unknown> = {
846
+ id: null,
847
+ fid,
848
+ parentId: null,
849
+ childrenIds: [childId],
850
+ role: "user",
851
+ content: prompt,
852
+ user_action: "chat",
853
+ files: [],
854
+ timestamp: nowSec,
855
+ models: [chatModel],
856
+ model: "",
857
+ chat_type: chatType,
858
+ feature_config: {
859
+ thinking_enabled: false,
860
+ output_schema: "phase",
861
+ research_mode: "normal",
862
+ auto_thinking: false,
863
+ thinking_mode: "Fast",
864
+ auto_search: true,
865
+ },
866
+ extra: {
867
+ meta: {
868
+ subChatType: chatType,
869
+ ...(size ? { size } : {}),
870
+ ...(generationModel ? { model: generationModel } : {}),
871
+ },
872
+ },
873
+ sub_chat_type: chatType,
874
+ parent_id: null,
875
+ };
876
+
877
+ const payload: Record<string, unknown> = {
878
+ stream: chatType !== "t2v",
879
+ version: "2.1",
880
+ incremental_output: true,
881
+ chatId,
882
+ parentId: "",
883
+ chat_id: chatId,
884
+ chat_mode: "normal",
885
+ messages: [userMessage],
886
+ model: chatModel,
887
+ parent_id: null,
888
+ timestamp: nowSec + 1,
889
+ };
890
+
891
+ if (size && (chatType === "t2i" || chatType === "t2v")) {
892
+ payload.size = size;
893
+ }
894
+
895
+ return payload;
896
+ }
897
+
898
+ interface SseParseResult {
899
+ content: string;
900
+ task_id?: string;
901
+ width?: number;
902
+ height?: number;
903
+ image_count?: number;
904
+ raw: string;
905
+ }
906
+
907
+ function parseSseResponse(raw: string): SseParseResult {
908
+ const result: SseParseResult = { content: "", raw };
909
+
910
+ const lines = raw.split("\n");
911
+ for (const line of lines) {
912
+ const trimmed = line.trim();
913
+ if (!trimmed.startsWith("data: ")) continue;
914
+
915
+ const dataStr = trimmed.slice(6);
916
+ if (dataStr === "[DONE]") continue;
917
+
918
+ try {
919
+ const parsed = JSON.parse(dataStr);
920
+
921
+ if (parsed?.choices?.[0]?.delta?.content) {
922
+ result.content = parsed.choices[0].delta.content;
923
+ } else if (parsed?.choices?.[0]?.message?.content) {
924
+ result.content = parsed.choices[0].message.content;
925
+ }
926
+
927
+ if (parsed?.task_id) {
928
+ result.task_id = parsed.task_id;
929
+ }
930
+
931
+ if (parsed?.usage) {
932
+ if (parsed.usage.width) result.width = parsed.usage.width;
933
+ if (parsed.usage.height) result.height = parsed.usage.height;
934
+ if (parsed.usage.image_count) result.image_count = parsed.usage.image_count;
935
+ }
936
+ } catch {
937
+ // Skip malformed SSE lines
938
+ }
939
+ }
940
+
941
+ return result;
942
+ }
943
+
944
+ function extractUrlFromContent(content: string): string | null {
945
+ const urlMatch = content.match(/https:\/\/[^\s"'<>]+/);
946
+ return urlMatch ? urlMatch[0] : null;
947
+ }
948
+
949
+ /** Safely parses a JSON body; returns null when it is not JSON. */
950
+ function parseJsonIfPossible(raw: string): unknown {
951
+ try {
952
+ return JSON.parse(raw);
953
+ } catch {
954
+ return null;
955
+ }
956
+ }
957
+
958
+ /**
959
+ * Cooldown for a RateLimited error. Uses the hours the upstream reports in its
960
+ * error message (e.g. "Please wait 4 hours...") when present; otherwise falls
961
+ * back to the account manager's default cooldown.
962
+ */
963
+ function rateLimitCooldownMs(err: UpstreamRateLimit): number | undefined {
964
+ const hourMatch = err.message?.match(/(\d+)\s*hours?/i);
965
+ if (hourMatch) {
966
+ const hours = Math.max(1, parseInt(hourMatch[1], 10));
967
+ return hours * 60 * 60 * 1000;
968
+ }
969
+ return undefined;
970
+ }
971
+
972
+ /**
973
+ * Detects a Qwen daily usage limit response. The upstream answers HTTP 200
974
+ * with `{"success":false,"data":{"code":"RateLimited","num":4}}` where `num` is
975
+ * the hours to wait. Mirrors the chat path's handling of `RateLimited` errors.
976
+ * Throws UpstreamRateLimit when detected; returns nothing otherwise.
977
+ */
978
+ function assertNotRateLimited(rawBody: string): void {
979
+ const json = parseJsonIfPossible(rawBody);
980
+ if (!json || typeof json !== "object") return;
981
+ const record = json as Record<string, unknown>;
982
+ const data = record.data as Record<string, unknown> | undefined;
983
+ const code = record.code ?? data?.code;
984
+ if (code !== "RateLimited") return;
985
+ const num = typeof data?.num === "number" ? data.num : 0;
986
+ const detail =
987
+ (num > 0 && typeof data?.template === "string"
988
+ ? data.template.replace(/\{\{\s*num\s*\}\}/g, String(num))
989
+ : "") ||
990
+ (typeof data?.details === "string" ? data.details : "") ||
991
+ "";
992
+ throw new UpstreamRateLimit(
993
+ detail ||
994
+ `Qwen daily usage limit reached${num > 0 ? `; retry in ~${num}h` : ""}`,
995
+ );
996
+ }
997
+
998
+ /**
999
+ * Task identifier from a video (t2v) response. Video uses stream:false, so the
1000
+ * response is JSON rather than SSE. Mirrors FreeQwenApi's extractTaskId:
1001
+ * prefers the wanx task id embedded in the first message, then falls back to
1002
+ * top-level response identifiers.
1003
+ */
1004
+ function extractTaskIdFromJson(value: unknown): string | null {
1005
+ if (!value || typeof value !== "object") return null;
1006
+ const data = value as Record<string, unknown>;
1007
+ const dataRecord = data.data as Record<string, unknown> | undefined;
1008
+ const messages = Array.isArray(dataRecord?.messages)
1009
+ ? (dataRecord.messages as unknown[])
1010
+ : [];
1011
+ const firstMessage = (messages[0] ?? null) as Record<string, unknown> | null;
1012
+ const wanxTaskId =
1013
+ (firstMessage?.extra as Record<string, unknown> | undefined)
1014
+ ?.wanx as Record<string, unknown> | undefined;
1015
+ if (wanxTaskId?.task_id && typeof wanxTaskId.task_id === "string") {
1016
+ return wanxTaskId.task_id;
1017
+ }
1018
+ const candidates = [
1019
+ data.id,
1020
+ data.task_id,
1021
+ data.response_id,
1022
+ dataRecord?.message_id,
1023
+ ];
1024
+ for (const candidate of candidates) {
1025
+ if (typeof candidate === "string" && candidate.length > 0) {
1026
+ return candidate;
1027
+ }
1028
+ }
1029
+ return null;
1030
+ }
1031
+
1032
+ // Media URL extraction copied from FreeQwenApi's core/qwen/media.js.
1033
+ // Qwen response structure is unstable, so the URL is searched recursively
1034
+ // through the entire response object, preferring real media file extensions
1035
+ // over generic service links.
1036
+ const VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm"];
1037
+ const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp"];
1038
+
1039
+ const PREFERRED_KEYS = [
1040
+ "video_url",
1041
+ "image_url",
1042
+ "url",
1043
+ "content",
1044
+ "result",
1045
+ "output",
1046
+ "data",
1047
+ "message",
1048
+ ];
1049
+
1050
+ function findMediaUrl(
1051
+ value: unknown,
1052
+ extensions: string[],
1053
+ seen: Set<unknown>,
1054
+ ): string | null {
1055
+ if (!value) return null;
1056
+
1057
+ if (typeof value === "string") {
1058
+ const urls = value.match(/https?:\/\/[^\s"'<>]+/g);
1059
+ if (!urls) return null;
1060
+ return (
1061
+ urls.find((url) =>
1062
+ extensions.some((ext) => url.toLowerCase().includes(ext)),
1063
+ ) || null
1064
+ );
1065
+ }
1066
+
1067
+ if (typeof value !== "object") return null;
1068
+ if (seen.has(value)) return null;
1069
+ seen.add(value);
1070
+
1071
+ if (Array.isArray(value)) {
1072
+ for (const item of value) {
1073
+ const found = findMediaUrl(item, extensions, seen);
1074
+ if (found) return found;
1075
+ }
1076
+ return null;
1077
+ }
1078
+
1079
+ const record = value as Record<string, unknown>;
1080
+ for (const key of PREFERRED_KEYS) {
1081
+ if (key in record) {
1082
+ const found = findMediaUrl(record[key], extensions, seen);
1083
+ if (found) return found;
1084
+ }
1085
+ }
1086
+ for (const item of Object.values(record)) {
1087
+ const found = findMediaUrl(item, extensions, seen);
1088
+ if (found) return found;
1089
+ }
1090
+ return null;
1091
+ }
1092
+
1093
+ function extractMediaUrl(
1094
+ value: unknown,
1095
+ type: "image" | "video" | "any" = "any",
1096
+ ): string | null {
1097
+ const extensions =
1098
+ type === "video"
1099
+ ? VIDEO_EXTENSIONS
1100
+ : type === "image"
1101
+ ? IMAGE_EXTENSIONS
1102
+ : [...VIDEO_EXTENSIONS, ...IMAGE_EXTENSIONS];
1103
+ return findMediaUrl(value, extensions, new Set());
1104
+ }
1105
+
1106
+ export async function generateImage(params: {
1107
+ prompt: string;
1108
+ model?: string;
1109
+ size?: string;
1110
+ accountId?: string;
1111
+ signal?: AbortSignal;
1112
+ }): Promise<ImageGenerationResult> {
1113
+ const {
1114
+ prompt,
1115
+ model: requestedModel,
1116
+ size,
1117
+ accountId: requestedAccountId,
1118
+ signal: externalSignal,
1119
+ } = params;
1120
+
1121
+ const normalizedSize = normalizeSize(size);
1122
+ const generationStartedAt = Date.now();
1123
+ const triedAccounts = new Set<string>();
1124
+ /** Accounts whose Baxia challenge was already solved in this call. */
1125
+ const captchaRecoveredAccounts = new Set<string>();
1126
+ /** Force header recapture on the next attempt (bx-* may rotate after a solved challenge). */
1127
+ let forceHeaderRefresh = false;
1128
+ let lastError: Error | null = null;
1129
+
1130
+ for (let attempt = 0; attempt < MAX_ACCOUNT_ATTEMPTS; attempt++) {
1131
+ const account = requestedAccountId
1132
+ ? { id: requestedAccountId, email: requestedAccountId }
1133
+ : getNextAvailableAccount(triedAccounts);
1134
+
1135
+ if (!account) {
1136
+ // A previous attempt already failed and no other account is available —
1137
+ // surface the aggregated error below instead of hiding it.
1138
+ if (lastError) break;
1139
+ throw new AuthError(
1140
+ "No available accounts for image generation",
1141
+ );
1142
+ }
1143
+
1144
+ triedAccounts.add(account.id);
1145
+
1146
+ const controller = new AbortController();
1147
+ const timeoutId = setTimeout(() => controller.abort(), IMAGE_TIMEOUT_MS);
1148
+
1149
+ const signal = externalSignal
1150
+ ? AbortSignal.any([controller.signal, externalSignal])
1151
+ : controller.signal;
1152
+
1153
+ try {
1154
+ const { chatModel, generationModel } = resolveMediaModel(requestedModel);
1155
+ logMediaInfo(
1156
+ mediaLog("image", "generation_started", {
1157
+ operation: "generate",
1158
+ account: shortMediaId(account.id),
1159
+ model: requestedModel,
1160
+ chat_model: chatModel,
1161
+ attempt: attempt + 1,
1162
+ size: normalizedSize ?? "auto",
1163
+ prompt_chars: prompt.length,
1164
+ }),
1165
+ );
1166
+
1167
+ const { headers } = await getQwenHeaders(forceHeaderRefresh, account.id);
1168
+ forceHeaderRefresh = false;
1169
+ const chatId = await createMediaChatSession(headers, chatModel, "t2i", signal, account.id);
1170
+
1171
+ logMediaDebug(
1172
+ mediaLog("image", "chat_created", {
1173
+ account: shortMediaId(account.id),
1174
+ chat: shortMediaId(chatId),
1175
+ }),
1176
+ );
1177
+
1178
+ const payload = buildCompletionsPayload(chatId, prompt, chatModel, "t2i", normalizedSize, generationModel);
1179
+ const requestHeaders = buildHeadersFromCaptured(headers, chatId);
1180
+
1181
+ const completionsResult = await requestCompletionsWithBrowserFallback({
1182
+ kind: "image",
1183
+ accountId: account.id,
1184
+ url: qwenUrl(`/api/v2/chat/completions?chat_id=${encodeURIComponent(chatId)}`),
1185
+ payloadJson: JSON.stringify(payload),
1186
+ headers: requestHeaders,
1187
+ chatId,
1188
+ streaming: true,
1189
+ signal,
1190
+ timeoutMs: IMAGE_TIMEOUT_MS,
1191
+ });
1192
+
1193
+ const { status: completionsStatus, rawBody } = completionsResult;
1194
+ if (completionsStatus !== 200) {
1195
+ throw new UpstreamError(
1196
+ `Image generation request failed: ${completionsStatus} ${rawBody.substring(0, 200)}`,
1197
+ );
1198
+ }
1199
+
1200
+ assertNotRateLimited(rawBody);
1201
+
1202
+ const sseResult = parseSseResponse(rawBody);
1203
+
1204
+ const imageUrl =
1205
+ extractUrlFromContent(sseResult.content) ||
1206
+ extractMediaUrl(sseResult.raw, "image");
1207
+ if (!imageUrl) {
1208
+ if (looksLikeAntiBotChallengeText(rawBody)) {
1209
+ const match = rawBody.match(/FAIL_SYS_USER_VALIDATE|RGV587_ERROR/i);
1210
+ const code = match ? match[0].toUpperCase() : "FAIL_SYS_USER_VALIDATE";
1211
+ logMediaWarn(
1212
+ mediaLog("image", "captcha_required", {
1213
+ account: shortMediaId(account.id),
1214
+ code,
1215
+ }),
1216
+ );
1217
+ const err = new UpstreamError(
1218
+ `Qwen anti-bot validation required: ${code}`,
1219
+ ) as UpstreamError & { upstreamCode: string };
1220
+ err.upstreamCode = code;
1221
+ throw err;
1222
+ }
1223
+ throw new UpstreamError(
1224
+ "No image URL found in generation response",
1225
+ );
1226
+ }
1227
+
1228
+ logMediaInfo(
1229
+ mediaLog("image", "generation_completed", {
1230
+ account: shortMediaId(account.id),
1231
+ chat: shortMediaId(chatId),
1232
+ duration_ms: Date.now() - generationStartedAt,
1233
+ output: "url",
1234
+ }),
1235
+ );
1236
+
1237
+ return {
1238
+ url: imageUrl,
1239
+ width: sseResult.width,
1240
+ height: sseResult.height,
1241
+ accountId: account.id,
1242
+ chatId,
1243
+ };
1244
+ } catch (err) {
1245
+ lastError = err instanceof Error ? err : new Error(String(err));
1246
+
1247
+ if (externalSignal?.aborted) {
1248
+ throw lastError;
1249
+ }
1250
+
1251
+ logMediaWarn(
1252
+ mediaLog("image", "attempt_failed", {
1253
+ account: shortMediaId(account.id),
1254
+ attempt: attempt + 1,
1255
+ elapsed_ms: Date.now() - generationStartedAt,
1256
+ error: lastError.message,
1257
+ }),
1258
+ );
1259
+
1260
+ if (
1261
+ isAntiBotError(lastError) &&
1262
+ !captchaRecoveredAccounts.has(account.id)
1263
+ ) {
1264
+ logMediaInfo(
1265
+ mediaLog("image", "captcha_recovery_started", {
1266
+ account: shortMediaId(account.id),
1267
+ }),
1268
+ );
1269
+ const recovered = await recoverBaxiaCaptcha(account.id, "media-generation");
1270
+ if (recovered) {
1271
+ captchaRecoveredAccounts.add(account.id);
1272
+ clearAccountCooldown(account.id);
1273
+ // Allow the same account to be picked again on the next attempt —
1274
+ // without this a single-account setup dies with "No available accounts".
1275
+ triedAccounts.delete(account.id);
1276
+ // bx-* tokens may rotate after a solved challenge — recapture headers.
1277
+ forceHeaderRefresh = true;
1278
+ logMediaInfo(
1279
+ mediaLog("image", "captcha_recovery_succeeded", {
1280
+ account: shortMediaId(account.id),
1281
+ }),
1282
+ );
1283
+ continue;
1284
+ }
1285
+ logMediaWarn(
1286
+ mediaLog("image", "captcha_recovery_failed", {
1287
+ account: shortMediaId(account.id),
1288
+ }),
1289
+ );
1290
+ }
1291
+
1292
+ if (lastError instanceof UpstreamRateLimit) {
1293
+ markAccountRateLimited(
1294
+ account.id,
1295
+ rateLimitCooldownMs(lastError),
1296
+ "RateLimited",
1297
+ );
1298
+ } else {
1299
+ markAccountRateLimited(account.id, ACCOUNT_COOLDOWN_MS, "MediaGenFailed");
1300
+ }
1301
+ } finally {
1302
+ clearTimeout(timeoutId);
1303
+ }
1304
+ }
1305
+
1306
+ if (lastError instanceof UpstreamRateLimit) {
1307
+ throw lastError;
1308
+ }
1309
+ throw new UpstreamError(
1310
+ `Image generation failed after ${MAX_ACCOUNT_ATTEMPTS} attempts: ${lastError?.message}`,
1311
+ );
1312
+ }
1313
+
1314
+ export async function generateVideo(params: {
1315
+ prompt: string;
1316
+ model?: string;
1317
+ size?: string;
1318
+ accountId?: string;
1319
+ waitForCompletion?: boolean;
1320
+ signal?: AbortSignal;
1321
+ }): Promise<VideoGenerationResult> {
1322
+ const {
1323
+ prompt,
1324
+ model: requestedModel,
1325
+ size,
1326
+ accountId: requestedAccountId,
1327
+ waitForCompletion = false,
1328
+ signal: externalSignal,
1329
+ } = params;
1330
+
1331
+ const normalizedSize = normalizeSize(size);
1332
+ const generationStartedAt = Date.now();
1333
+ const triedAccounts = new Set<string>();
1334
+ /** Accounts whose Baxia challenge was already solved in this call. */
1335
+ const captchaRecoveredAccounts = new Set<string>();
1336
+ /** Force header recapture on the next attempt (bx-* may rotate after a solved challenge). */
1337
+ let forceHeaderRefresh = false;
1338
+ let lastError: Error | null = null;
1339
+
1340
+ for (let attempt = 0; attempt < MAX_ACCOUNT_ATTEMPTS; attempt++) {
1341
+ const account = requestedAccountId
1342
+ ? { id: requestedAccountId, email: requestedAccountId }
1343
+ : getNextAvailableAccount(triedAccounts);
1344
+
1345
+ if (!account) {
1346
+ // A previous attempt already failed and no other account is available —
1347
+ // surface the aggregated error below instead of hiding it.
1348
+ if (lastError) break;
1349
+ throw new AuthError(
1350
+ "No available accounts for video generation",
1351
+ );
1352
+ }
1353
+
1354
+ triedAccounts.add(account.id);
1355
+
1356
+ const controller = new AbortController();
1357
+ const timeoutId = setTimeout(() => controller.abort(), VIDEO_TIMEOUT_MS);
1358
+
1359
+ const signal = externalSignal
1360
+ ? AbortSignal.any([controller.signal, externalSignal])
1361
+ : controller.signal;
1362
+
1363
+ try {
1364
+ const { chatModel, generationModel } = resolveMediaModel(requestedModel);
1365
+ logMediaInfo(
1366
+ mediaLog("video", "generation_started", {
1367
+ operation: "generate",
1368
+ account: shortMediaId(account.id),
1369
+ model: requestedModel,
1370
+ chat_model: chatModel,
1371
+ attempt: attempt + 1,
1372
+ size: normalizedSize ?? "16:9",
1373
+ prompt_chars: prompt.length,
1374
+ wait: waitForCompletion,
1375
+ }),
1376
+ );
1377
+
1378
+ const { headers } = await getQwenHeaders(forceHeaderRefresh, account.id);
1379
+ forceHeaderRefresh = false;
1380
+ const chatId = await createMediaChatSession(headers, chatModel, "t2v", signal, account.id);
1381
+
1382
+ logMediaDebug(
1383
+ mediaLog("video", "chat_created", {
1384
+ account: shortMediaId(account.id),
1385
+ chat: shortMediaId(chatId),
1386
+ }),
1387
+ );
1388
+
1389
+ const payload = buildCompletionsPayload(chatId, prompt, chatModel, "t2v", normalizedSize, generationModel);
1390
+ const requestHeaders = buildHeadersFromCaptured(headers, chatId);
1391
+
1392
+ const completionsResult = await requestCompletionsWithBrowserFallback({
1393
+ kind: "video",
1394
+ accountId: account.id,
1395
+ url: qwenUrl(`/api/v2/chat/completions?chat_id=${encodeURIComponent(chatId)}`),
1396
+ payloadJson: JSON.stringify(payload),
1397
+ headers: requestHeaders,
1398
+ chatId,
1399
+ streaming: true,
1400
+ signal,
1401
+ timeoutMs: VIDEO_TIMEOUT_MS,
1402
+ });
1403
+
1404
+ const { status: completionsStatus, rawBody } = completionsResult;
1405
+ if (completionsStatus !== 200) {
1406
+ throw new UpstreamError(
1407
+ `Video generation request failed: ${completionsStatus} ${rawBody.substring(0, 200)}`,
1408
+ );
1409
+ }
1410
+
1411
+ assertNotRateLimited(rawBody);
1412
+
1413
+ const sseResult = parseSseResponse(rawBody);
1414
+ const taskId =
1415
+ sseResult.task_id ?? extractTaskIdFromJson(parseJsonIfPossible(rawBody));
1416
+
1417
+ if (!taskId) {
1418
+ const videoUrl =
1419
+ extractUrlFromContent(sseResult.content) ||
1420
+ extractMediaUrl(sseResult.raw, "video") ||
1421
+ extractMediaUrl(parseJsonIfPossible(rawBody), "video");
1422
+ if (videoUrl) {
1423
+ logMediaInfo(
1424
+ mediaLog("video", "generation_completed", {
1425
+ account: shortMediaId(account.id),
1426
+ chat: shortMediaId(chatId),
1427
+ duration_ms: Date.now() - generationStartedAt,
1428
+ output: "url",
1429
+ source: "direct",
1430
+ }),
1431
+ );
1432
+ return {
1433
+ task_id: "",
1434
+ status: "completed",
1435
+ video_url: videoUrl,
1436
+ accountId: account.id,
1437
+ chatId,
1438
+ };
1439
+ }
1440
+ logMediaWarn(
1441
+ mediaLog("video", "response_invalid", {
1442
+ account: shortMediaId(account.id),
1443
+ chat: shortMediaId(chatId),
1444
+ http_status: completionsStatus,
1445
+ response_chars: rawBody.length,
1446
+ }),
1447
+ );
1448
+ throw new UpstreamError(
1449
+ "No task_id or video URL found in video generation response",
1450
+ );
1451
+ }
1452
+
1453
+ logMediaInfo(
1454
+ mediaLog("video", "task_submitted", {
1455
+ account: shortMediaId(account.id),
1456
+ chat: shortMediaId(chatId),
1457
+ task: shortMediaId(taskId),
1458
+ wait: waitForCompletion,
1459
+ }),
1460
+ );
1461
+
1462
+ if (!waitForCompletion) {
1463
+ return {
1464
+ task_id: taskId,
1465
+ status: "pending",
1466
+ accountId: account.id,
1467
+ chatId,
1468
+ };
1469
+ }
1470
+
1471
+ const status = await pollVideoTask({
1472
+ taskId,
1473
+ accountId: account.id,
1474
+ signal,
1475
+ });
1476
+
1477
+ return {
1478
+ task_id: taskId,
1479
+ status: status.status,
1480
+ video_url: status.video_url,
1481
+ accountId: account.id,
1482
+ chatId,
1483
+ };
1484
+ } catch (err) {
1485
+ lastError = err instanceof Error ? err : new Error(String(err));
1486
+
1487
+ if (externalSignal?.aborted) {
1488
+ throw lastError;
1489
+ }
1490
+
1491
+ logMediaWarn(
1492
+ mediaLog("video", "attempt_failed", {
1493
+ account: shortMediaId(account.id),
1494
+ attempt: attempt + 1,
1495
+ elapsed_ms: Date.now() - generationStartedAt,
1496
+ error: lastError.message,
1497
+ }),
1498
+ );
1499
+
1500
+ if (
1501
+ isAntiBotError(lastError) &&
1502
+ !captchaRecoveredAccounts.has(account.id)
1503
+ ) {
1504
+ logMediaInfo(
1505
+ mediaLog("video", "captcha_recovery_started", {
1506
+ account: shortMediaId(account.id),
1507
+ }),
1508
+ );
1509
+ const recovered = await recoverBaxiaCaptcha(
1510
+ account.id,
1511
+ "media-generation",
1512
+ );
1513
+ if (recovered) {
1514
+ captchaRecoveredAccounts.add(account.id);
1515
+ clearAccountCooldown(account.id);
1516
+ // Allow the same account to be picked again on the next attempt —
1517
+ // without this a single-account setup dies with "No available accounts".
1518
+ triedAccounts.delete(account.id);
1519
+ // bx-* tokens may rotate after a solved challenge — recapture headers.
1520
+ forceHeaderRefresh = true;
1521
+ logMediaInfo(
1522
+ mediaLog("video", "captcha_recovery_succeeded", {
1523
+ account: shortMediaId(account.id),
1524
+ }),
1525
+ );
1526
+ continue;
1527
+ }
1528
+ logMediaWarn(
1529
+ mediaLog("video", "captcha_recovery_failed", {
1530
+ account: shortMediaId(account.id),
1531
+ }),
1532
+ );
1533
+ }
1534
+
1535
+ if (lastError instanceof UpstreamRateLimit) {
1536
+ markAccountRateLimited(
1537
+ account.id,
1538
+ rateLimitCooldownMs(lastError),
1539
+ "RateLimited",
1540
+ );
1541
+ } else {
1542
+ markAccountRateLimited(account.id, ACCOUNT_COOLDOWN_MS, "MediaGenFailed");
1543
+ }
1544
+ } finally {
1545
+ clearTimeout(timeoutId);
1546
+ }
1547
+ }
1548
+
1549
+ if (lastError instanceof UpstreamRateLimit) {
1550
+ throw lastError;
1551
+ }
1552
+ throw new UpstreamError(
1553
+ `Video generation failed after ${MAX_ACCOUNT_ATTEMPTS} attempts: ${lastError?.message}`,
1554
+ );
1555
+ }
1556
+
1557
+ export async function pollVideoTask(params: {
1558
+ taskId: string;
1559
+ accountId: string;
1560
+ signal?: AbortSignal;
1561
+ /** Single upstream poll without looping; used for non-blocking status checks. */
1562
+ once?: boolean;
1563
+ }): Promise<VideoTaskStatus> {
1564
+ const { taskId, accountId, signal, once } = params;
1565
+
1566
+ const controller = new AbortController();
1567
+ const timeoutId = setTimeout(() => controller.abort(), VIDEO_TIMEOUT_MS);
1568
+
1569
+ const effectiveSignal = signal
1570
+ ? AbortSignal.any([controller.signal, signal])
1571
+ : controller.signal;
1572
+ const pollStartedAt = Date.now();
1573
+ let pollCount = 0;
1574
+ let lastProgressLogAt = 0;
1575
+ let lastLoggedStatus = "";
1576
+
1577
+ try {
1578
+ const { headers } = await getQwenHeaders(false, accountId);
1579
+ const requestHeaders = buildHeadersFromCaptured(headers);
1580
+ const pollUrl = qwenUrl(`/api/v1/tasks/status/${encodeURIComponent(taskId)}`);
1581
+
1582
+ logMediaDebug(
1583
+ mediaLog("video", "task_polling_started", {
1584
+ account: shortMediaId(accountId),
1585
+ task: shortMediaId(taskId),
1586
+ mode: once ? "once" : "wait",
1587
+ }),
1588
+ );
1589
+
1590
+ while (!effectiveSignal.aborted) {
1591
+ pollCount += 1;
1592
+ let json: Record<string, unknown> | null = null;
1593
+ if (!config.qwen.browserOnlyFetch) {
1594
+ const nodeResponse = await fetch(pollUrl, {
1595
+ method: "GET",
1596
+ headers: requestHeaders,
1597
+ signal: effectiveSignal,
1598
+ });
1599
+
1600
+ if (nodeResponse.ok) {
1601
+ const rawBody = await nodeResponse.text().catch(() => "");
1602
+ if (!looksLikeAntiBotChallengeText(rawBody)) {
1603
+ json = parseJsonIfPossible(rawBody) as Record<string, unknown> | null;
1604
+ }
1605
+ }
1606
+ }
1607
+ if (!json) {
1608
+ try {
1609
+ const browserJson = await fetchJsonInBrowser(accountId, pollUrl);
1610
+ json = browserJson as Record<string, unknown> | null;
1611
+ } catch (error) {
1612
+ logMediaWarn(
1613
+ mediaLog("video", "task_poll_fallback_failed", {
1614
+ account: shortMediaId(accountId),
1615
+ task: shortMediaId(taskId),
1616
+ error: error instanceof Error ? error.message : String(error),
1617
+ }),
1618
+ );
1619
+ }
1620
+ }
1621
+
1622
+ if (!json) {
1623
+ throw new UpstreamError(
1624
+ `Failed to poll video task: no response from Node or browser paths`,
1625
+ );
1626
+ }
1627
+
1628
+ const status =
1629
+ (json.task_status as string) ||
1630
+ (json.status as string) ||
1631
+ "running";
1632
+ const videoUrl =
1633
+ (json.video_url as string) ||
1634
+ ((json.data as Record<string, unknown> | undefined)
1635
+ ?.video_url as string | undefined) ||
1636
+ ((json.output as Record<string, unknown> | undefined)
1637
+ ?.video_url as string | undefined) ||
1638
+ extractMediaUrl(json, "video") ||
1639
+ undefined;
1640
+ const error =
1641
+ (json.error as string) ||
1642
+ ((json.data as Record<string, unknown> | undefined)
1643
+ ?.error as string | undefined) ||
1644
+ undefined;
1645
+
1646
+ if (
1647
+ status === "completed" ||
1648
+ status === "success" ||
1649
+ status === "failed" ||
1650
+ status === "error"
1651
+ ) {
1652
+ const finished = status === "completed" || status === "success";
1653
+ logMediaInfo(
1654
+ mediaLog("video", "task_finished", {
1655
+ account: shortMediaId(accountId),
1656
+ task: shortMediaId(taskId),
1657
+ status,
1658
+ result: finished ? "completed" : "failed",
1659
+ polls: pollCount,
1660
+ elapsed_ms: Date.now() - pollStartedAt,
1661
+ error: finished ? undefined : error,
1662
+ }),
1663
+ );
1664
+ return {
1665
+ status: finished ? "completed" : "failed",
1666
+ video_url: videoUrl,
1667
+ error: finished ? undefined : error || `Task ended with status: ${status}`,
1668
+ };
1669
+ }
1670
+
1671
+ const now = Date.now();
1672
+ if (
1673
+ once ||
1674
+ pollCount === 1 ||
1675
+ status !== lastLoggedStatus ||
1676
+ now - lastProgressLogAt >= 30_000
1677
+ ) {
1678
+ logMediaDebug(
1679
+ mediaLog("video", "task_polling", {
1680
+ account: shortMediaId(accountId),
1681
+ task: shortMediaId(taskId),
1682
+ status,
1683
+ poll: pollCount,
1684
+ elapsed_ms: now - pollStartedAt,
1685
+ mode: once ? "once" : undefined,
1686
+ }),
1687
+ );
1688
+ lastProgressLogAt = now;
1689
+ lastLoggedStatus = status;
1690
+ }
1691
+
1692
+ if (once) {
1693
+ return {
1694
+ status: "pending",
1695
+ video_url: videoUrl,
1696
+ error: undefined,
1697
+ };
1698
+ }
1699
+
1700
+ await new Promise((resolve) => setTimeout(resolve, VIDEO_POLL_INTERVAL_MS));
1701
+ }
1702
+
1703
+ logMediaWarn(
1704
+ mediaLog("video", "task_polling_aborted", {
1705
+ account: shortMediaId(accountId),
1706
+ task: shortMediaId(taskId),
1707
+ polls: pollCount,
1708
+ elapsed_ms: Date.now() - pollStartedAt,
1709
+ }),
1710
+ );
1711
+ return { status: "pending", error: "Polling aborted" };
1712
+ } finally {
1713
+ clearTimeout(timeoutId);
1714
+ }
1715
+ }
1716
+
1717
+ /** GET a URL inside the account's Playwright page (live session, no WAF). */
1718
+ async function fetchJsonInBrowser(
1719
+ accountId: string,
1720
+ url: string,
1721
+ ): Promise<unknown> {
1722
+ return withAccountPage(
1723
+ accountId,
1724
+ (page) =>
1725
+ page.evaluate(
1726
+ async (req) => {
1727
+ try {
1728
+ const response = await fetch(req.url, {
1729
+ method: "GET",
1730
+ credentials: "include",
1731
+ headers: {
1732
+ Accept: "application/json",
1733
+ source: "web",
1734
+ },
1735
+ });
1736
+ if (!response.ok) return null;
1737
+ return await response.json();
1738
+ } catch {
1739
+ return null;
1740
+ }
1741
+ },
1742
+ { url },
1743
+ ),
1744
+ 30_000,
1745
+ 30_000,
1746
+ );
1747
+ }
1748
+