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