qwenproxy-cli 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -14
- package/README.md +906 -906
- package/bin/qwenproxy.js +5 -1
- package/package.json +77 -78
- package/src/api/error-classifier.ts +159 -159
- package/src/api/error-helpers.ts +118 -118
- package/src/api/models.ts +261 -261
- package/src/api/server.ts +860 -859
- package/src/cache/memory-cache.ts +385 -385
- package/src/clean-cache.ts +204 -204
- package/src/core/account-concurrency.ts +671 -671
- package/src/core/account-manager.ts +301 -297
- package/src/core/account-priority.ts +163 -163
- package/src/core/accounts.ts +186 -186
- package/src/core/config.ts +383 -383
- package/src/core/crypto-utils.ts +79 -79
- package/src/core/database.ts +276 -276
- package/src/core/errors.ts +118 -118
- package/src/core/logger.ts +269 -269
- package/src/core/memory-usage.ts +84 -84
- package/src/core/metrics.ts +291 -291
- package/src/core/model-alias.ts +77 -77
- package/src/core/model-registry.ts +544 -544
- package/src/core/mutex.ts +119 -119
- package/src/core/paths.ts +199 -199
- package/src/core/prompt-limits.ts +214 -214
- package/src/core/reasoning-effort.ts +102 -102
- package/src/core/stream-registry.ts +96 -96
- package/src/core/waf-isolation.ts +117 -117
- package/src/core/watchdog.ts +195 -195
- package/src/delete-chats.ts +23 -23
- package/src/index.ts +65 -64
- package/src/login.ts +147 -147
- package/src/reset-cooldowns.ts +11 -11
- package/src/routes/anthropic/index.ts +355 -355
- package/src/routes/anthropic/translate.ts +522 -522
- package/src/routes/anthropic/types.ts +154 -154
- package/src/routes/anthropic/validation.ts +144 -144
- package/src/routes/chat/account.ts +1817 -1817
- package/src/routes/chat/context.ts +241 -241
- package/src/routes/chat/errors.ts +85 -85
- package/src/routes/chat/helpers.ts +268 -268
- package/src/routes/chat/index.ts +618 -618
- package/src/routes/chat/media.ts +285 -285
- package/src/routes/chat/retry-policy.ts +754 -754
- package/src/routes/chat/stop.ts +98 -98
- package/src/routes/chat/streaming.ts +2710 -2710
- package/src/routes/chat/validation.ts +526 -526
- package/src/routes/chat.ts +2 -2
- package/src/routes/completions.ts +290 -290
- package/src/routes/images.ts +139 -139
- package/src/routes/responses/adapter.ts +503 -503
- package/src/routes/responses/index.ts +405 -405
- package/src/routes/responses/state.ts +230 -230
- package/src/routes/responses/streaming.ts +528 -528
- package/src/routes/responses/types.ts +285 -285
- package/src/routes/responses/validation.ts +202 -202
- package/src/routes/upload.ts +731 -731
- package/src/routes/videos.ts +214 -214
- package/src/services/auth-playwright.ts +173 -173
- package/src/services/captcha-coordinator.ts +161 -161
- package/src/services/captcha-solver.ts +553 -553
- package/src/services/chat-cleanup.ts +80 -80
- package/src/services/context-meter.ts +317 -317
- package/src/services/fingerprint.ts +242 -242
- package/src/services/human-behavior.ts +173 -173
- package/src/services/media-generation.ts +1748 -1748
- package/src/services/playwright.ts +2878 -2800
- package/src/services/qwen-chat-pool.ts +345 -345
- package/src/services/qwen-errors.ts +133 -133
- package/src/services/qwen-headers.ts +79 -79
- package/src/services/qwen-thread-state.ts +393 -393
- package/src/services/qwen-url.ts +19 -19
- package/src/services/qwen.ts +3126 -3126
- package/src/services/session-keeper.ts +88 -88
- package/src/services/token-estimation-metrics.ts +118 -118
- package/src/sync/claude-code.ts +75 -75
- package/src/sync/codex.ts +123 -123
- package/src/sync/index.ts +362 -362
- package/src/sync/omp.ts +105 -105
- package/src/sync/opencode.ts +214 -214
- package/src/sync/types.ts +53 -53
- package/src/sync/utils.ts +27 -27
- package/src/sync-clients.ts +189 -189
- package/src/tools/instructions.ts +137 -137
- package/src/tools/manifest.ts +81 -81
- package/src/tools/parser.ts +2989 -2989
- package/src/tools/toolcall-tags.ts +142 -142
- package/src/tui/app.ts +259 -264
- package/src/tui/index.ts +61 -61
- package/src/tui/markdown.ts +258 -258
- package/src/tui/proxy-client.ts +331 -326
- package/src/tui/screen.ts +294 -278
- package/src/tui/server-manager.ts +270 -270
- package/src/tui/theme.ts +432 -432
- package/src/tui/types.ts +33 -33
- package/src/tui/views/accounts-view.ts +656 -656
- package/src/tui/views/chat-view.ts +1018 -823
- package/src/tui/views/logs-view.ts +479 -413
- package/src/tui/views/status-view.ts +204 -204
- package/src/tui/views/storage-view.ts +304 -291
- package/src/tui/views/sync-view.ts +409 -409
- package/src/types/ali-oss.d.ts +32 -32
- package/src/update-cli.ts +121 -0
- package/src/utils/context-truncation.ts +84 -84
- package/src/utils/json.ts +380 -380
- package/src/utils/session-id.ts +37 -37
- package/src/utils/tool-call-guard.ts +84 -84
- package/src/utils/types.ts +109 -109
|
@@ -1,1748 +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
|
-
|
|
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
|
+
|