diff --git a/src/controllers/anthropic.js b/src/controllers/anthropic.js
index d1f6344c..186300ac 100644
--- a/src/controllers/anthropic.js
+++ b/src/controllers/anthropic.js
@@ -1262,8 +1262,6 @@ const handleAnthropicStream = async (res, ctx, upstream) => {
let textBlockOpen = false;
let thinkingBlockOpen = false;
let thinkingSignature = null;
- let promptTokens = 0;
- let completionTokens = 0;
let upstreamUsage = null; // 上游逐帧累计的 usage(DashScope 命名已归一化;null = 还没报)
let upstreamFinishReason = null;
let upstreamCompleted;
@@ -2001,8 +1999,8 @@ const handleAnthropicStream = async (res, ctx, upstream) => {
// 只对上游没报的字段补本地估算(早停的回合收不到尾部 usage 帧)
const usage = reportUsage(upstreamUsage, () => createUsageObject(requestBody?.messages || '', completionContent), 'ANTHROPIC');
- promptTokens = usage.prompt_tokens;
- completionTokens = usage.completion_tokens;
+ const promptTokens = usage.prompt_tokens;
+ const completionTokens = usage.completion_tokens;
// Daily stats 累计——一次性归属主账户(见模块顶部 attributeChatUsage 注释)
attributeChatUsage(ctx.currentAccount, promptTokens, completionTokens);
@@ -2041,8 +2039,6 @@ const handleAnthropicNonStream = async (res, ctx, upstream) => {
// 与流式分支同一条纪律,上一轮的泄漏已经重试过了。
let attemptThinkingContent = '';
let answerContent = '';
- let promptTokens = 0;
- let completionTokens = 0;
let upstreamUsage = null; // 上游逐帧累计的 usage(DashScope 命名已归一化;null = 还没报)
let webSearchInfo = null;
let upstreamFinishReason = null;
@@ -2577,8 +2573,8 @@ const handleAnthropicNonStream = async (res, ctx, upstream) => {
const nativeArgsText = nativeToolCalls.map(call => call.function.arguments || '').join('');
return createUsageObject(requestBody?.messages || '', thinkingContent + answerContent + nativeArgsText);
}, 'ANTHROPIC');
- promptTokens = usage.prompt_tokens;
- completionTokens = usage.completion_tokens;
+ const promptTokens = usage.prompt_tokens;
+ const completionTokens = usage.completion_tokens;
const contentBlocks = [];
if (thinkingContent && thinkingContent.trim()) {
diff --git a/src/controllers/chat.image.video.js b/src/controllers/chat.image.video.js
index 756e49c6..2ce6aaec 100644
--- a/src/controllers/chat.image.video.js
+++ b/src/controllers/chat.image.video.js
@@ -10,6 +10,7 @@ const { getDefaultModelByChatType } = require('../models/models-map.js')
const { getSsxmodForAccount } = require('../utils/ssxmod-manager')
const { applyProxyToAxiosConfig, getChatBaseUrl } = require('../utils/proxy-helper');
const { buildRequestHeaders } = require('../utils/header-profile')
+const { detectWafChallenge } = require('../utils/upstream-error.js')
const DATA_URI_REGEX = /^data:(.+);base64,(.*)$/i
const HTTP_URL_REGEX = /^https?:\/\//i
@@ -78,6 +79,22 @@ const parseUpstreamImageError = (data) => {
payload = JSON.parse(payload)
}
+ // WAF/captcha llega como HTTP 200 con una forma propia (`ret`), NO como
+ // `success:false`. Sin esta rama el paquete se colaba entero: la generación
+ // "tenía éxito" y se devolvía la imagen de relleno de Qwen como resultado.
+ const wafChallenge = detectWafChallenge(payload)
+ if (wafChallenge) {
+ logger.error('图片/视频上游触发 WAF/captcha,需人工验证', 'CHAT', '', {
+ parsed_error: wafChallenge.details,
+ raw_response_body: rawPayload
+ })
+ return {
+ error: wafChallenge.publicMessage,
+ code: wafChallenge.code,
+ status: 502
+ }
+ }
+
// 只有明确 success=false 且带错误码时,才按上游错误包处理,避免误伤正常业务响应
if (!payload || payload.success !== false || !payload.data?.code) {
return null
@@ -126,6 +143,54 @@ const parseUpstreamImageErrorFromText = (text) => {
}
}
+/**
+ * 流结束后的兜底:从整段原始文本里找出可识别的上游错误。
+ *
+ * 不能只试 `JSON.parse(整段)`:上游有时把 JSON 包在 `data:` 前缀里发(额度耗尽的
+ * 非流式响应就是这样),整段 JSON.parse 会失败,WAF/业务错误就整个滑过去被当成成功。
+ * 这里复用流式路径同一个 parseSsePayloads,两种形态都拆得开。
+ * @param {string} text - 上游原始文本
+ * @returns {object|null} 上游错误,未识别到则为 null
+ */
+const parseUpstreamErrorFromRawText = (text) => {
+ if (!text || typeof text !== 'string') {
+ return null
+ }
+
+ const trimmed = text.trim()
+ if (!trimmed) {
+ return null
+ }
+
+ // La página de captcha del WAF llega como HTML y no se deja ver por ninguna de las
+ // ramas de abajo (no hay `data:`, no es JSON). Se comprueba primero.
+ const htmlChallenge = detectWafChallenge(trimmed)
+ if (htmlChallenge) {
+ logger.error('图片/视频上游返回 WAF/captcha HTML 页面,需人工验证', 'CHAT', '', {
+ raw_preview: trimmed.slice(0, 200)
+ })
+ return {
+ error: htmlChallenge.publicMessage,
+ code: htmlChallenge.code,
+ status: 502
+ }
+ }
+
+ const fromWholeText = parseUpstreamImageErrorFromText(trimmed)
+ if (fromWholeText) {
+ return fromWholeText
+ }
+
+ for (const payload of parseSsePayloads(trimmed, true).payloads) {
+ const parsed = parseUpstreamImageError(payload)
+ if (parsed) {
+ return parsed
+ }
+ }
+
+ return null
+}
+
/**
* 收集对象中的所有值
* @param {*} payload - 任意负载
@@ -882,7 +947,7 @@ const readVideoUpstreamResult = async (responseStream) => {
const trimmedRawText = rawText.trim()
if (!upstreamError) {
- upstreamError = parseUpstreamImageErrorFromText(trimmedRawText) || parseUpstreamImageError(trimmedRawText)
+ upstreamError = parseUpstreamErrorFromRawText(trimmedRawText)
}
if (!contentUrl) {
@@ -969,7 +1034,7 @@ const readImageUpstreamResult = async (responseStream) => {
const trimmedRawText = rawText.trim()
if (!upstreamError) {
- upstreamError = parseUpstreamImageErrorFromText(trimmedRawText) || parseUpstreamImageError(trimmedRawText)
+ upstreamError = parseUpstreamErrorFromRawText(trimmedRawText)
}
if (!contentUrl) {
@@ -1328,6 +1393,12 @@ const generateImageVideoResult = async (payload) => {
const chatBaseUrl = getChatBaseUrl()
// Antidetect: per-account fingerprint headers replace static block
const ssxmod = getSsxmodForAccount(account)
+ // The Accept header depends on whether the upstream call streams, so the
+ // flag has to be resolved before the headers are built. Declaring it after
+ // this block put it in the temporal dead zone and threw
+ // "Cannot access 'upstreamStream' before initialization" on every call.
+ const newChatType = reqBody.messages[0]?.chat_type
+ const upstreamStream = newChatType === 't2i' || newChatType === 'image_edit'
const headers = buildRequestHeaders(account, {
chatBaseUrl,
token,
@@ -1344,8 +1415,6 @@ const generateImageVideoResult = async (payload) => {
logger.info(`选择图片: ${selectedImageList[selectedImageList.length - 1] || '未选择图片,切换生成图/视频模式'}`, 'CHAT')
logger.info(`使用提示: ${reqBody.messages[0].content}`, 'CHAT')
- const newChatType = reqBody.messages[0].chat_type
- const upstreamStream = newChatType === 't2i' || newChatType === 'image_edit'
reqBody.stream = upstreamStream
logger.info(`图片视频流策略: upstream=${upstreamStream} downstream=${payload.stream === true}`, 'CHAT')
diff --git a/src/utils/upstream-error.js b/src/utils/upstream-error.js
index 29c4eec6..251614fe 100644
--- a/src/utils/upstream-error.js
+++ b/src/utils/upstream-error.js
@@ -23,6 +23,83 @@ const RATE_LIMIT_CODE = 'RateLimited';
const QUOTA_LIMIT_CODE = 'quota_limit';
const WAF_CHALLENGE_CODE = 'upstream_waf_challenge';
const isWafChallengeError = (error) => String(error?.code || '').toLowerCase() === WAF_CHALLENGE_CODE;
+/**
+ * Señales con las que Qwen Web anuncia el WAF/captcha. Vive aquí porque hay DOS rutas que
+ * tienen que reconocerlo —los controladores de texto vía assertNoUpstreamFailure, y el de
+ * imagen/vídeo vía parseUpstreamImageError— y separarlas ya costó un fallo silencioso: la
+ * ruta de imagen no reconocía este paquete y entregaba un 200 con la imagen de relleno del
+ * propio Qwen (img.alicdn.com) como si la generación hubiera salido bien.
+ */
+const WAF_SIGNAL_RE = /FAIL_SYS_USER_VALIDATE|RGV587|captcha|\/punish\?/i;
+/**
+ * Señales de la MISMA página de captcha pero cuando el upstream la manda como HTML.
+ *
+ * Caso real (2026-09-19): `/api/v2/chat/completions` contestó 200 con 16 KB de
+ * ` `. El detector solo miraba el paquete
+ * JSON `{ret:[...]}`, así que el HTML pasaba entero: `parseSsePayloads` no encontraba
+ * ninguna línea `data:`, `JSON.parse` fallaba, y `extractResourceUrlFromPayload` sacaba
+ * del propio HTML la primera URL que hubiera — que resultó ser la imagen de relleno de
+ * Qwen — y la devolvía como generación correcta.
+ *
+ * Ancladas a marcadores que solo existen en el challenge, no a la palabra suelta
+ * "captcha": el texto normal del modelo puede nombrarla.
+ */
+const WAF_HTML_SIGNAL_RE = /aliyun_waf_|aliyunCaptcha|_waf_is_mobile|id=["']captcha-element|
Verification<\/title>/i;
+/**
+ * ¿El cuerpo CRUDO del upstream (texto o buffer) es la página de captcha del WAF?
+ * @param {unknown} rawText - Cuerpo sin parsear
+ * @returns {boolean}
+ */
+const isWafChallengeBody = (rawText) => {
+ if (typeof rawText !== 'string' || rawText === '') return false;
+ return WAF_HTML_SIGNAL_RE.test(rawText);
+};
+/**
+ * ¿Este payload de upstream (HTTP 200, JSON normal) es en realidad el WAF pidiendo captcha?
+ *
+ * Qwen contesta 200 con `{"ret":["FAIL_SYS_USER_VALIDATE",...],"data":{"url":".../punish?..."}}`
+ * en vez de un error HTTP. Sin reconocerlo, el llamador lo trata como respuesta buena.
+ * @param {unknown} payload - Cuerpo JSON del upstream
+ * @returns {string[]} Señales encontradas (vacío si no es un challenge)
+ */
+const findWafChallengeSignals = (payload) => {
+ if (!payload || typeof payload !== 'object') return [];
+ const ret = Array.isArray(payload.ret)
+ ? payload.ret.map(String)
+ : (payload.ret ? [String(payload.ret)] : []);
+ return [
+ ...ret,
+ payload.code,
+ payload.data?.code,
+ payload.data?.url,
+ payload.error?.code
+ ].filter(Boolean).map(String).filter(item => WAF_SIGNAL_RE.test(item));
+};
+/**
+ * Detecta el challenge y devuelve el error canónico, o null si el payload no lo es.
+ * @param {unknown} payload - Cuerpo JSON del upstream
+ * @returns {UpstreamResponseError|null}
+ */
+const detectWafChallenge = (payload) => {
+ // El cuerpo crudo llega a veces tal cual (HTML). Se comprueba primero porque un HTML
+ // no tiene forma de objeto que inspeccionar.
+ if (typeof payload === 'string' && isWafChallengeBody(payload)) {
+ return new UpstreamResponseError(
+ 'Qwen 网页上游触发 WAF/captcha;Agent 上下文可能过大或账号需要验证',
+ WAF_CHALLENGE_CODE,
+ { ret: [] }
+ );
+ }
+ if (!payload || typeof payload !== 'object') return null;
+ const signals = findWafChallengeSignals(payload);
+ if (signals.length === 0) return null;
+ const ret = Array.isArray(payload?.ret) ? payload.ret.map(String) : [];
+ return new UpstreamResponseError(
+ 'Qwen 网页上游触发 WAF/captcha;Agent 上下文可能过大或账号需要验证',
+ WAF_CHALLENGE_CODE,
+ { ret }
+ );
+};
/** Vocabulario de cable de cada API. Juntos aqui para que los gemelos no se separen. */
const RATE_LIMIT_ANTHROPIC_TYPE = 'rate_limit_error';
const RATE_LIMIT_OPENAI_TYPE = 'insufficient_quota';
@@ -177,26 +254,10 @@ const noteRateLimitedAccount = (error, account) => {
* 这些帧没有 choices,若直接跳过就会被误包装成空成功或正常 stop。
*/
const assertNoUpstreamFailure = (payload) => {
+ const wafChallenge = detectWafChallenge(payload);
+ if (wafChallenge) throw wafChallenge;
if (!payload || typeof payload !== 'object') return;
- const ret = Array.isArray(payload.ret)
- ? payload.ret.map(String)
- : (payload.ret ? [String(payload.ret)] : []);
- const upstreamSignals = [
- ...ret,
- payload.code,
- payload.data?.code,
- payload.data?.url,
- payload.error?.code
- ].filter(Boolean).map(String);
- if (upstreamSignals.some(item => item.toLowerCase() === WAF_CHALLENGE_CODE || /FAIL_SYS_USER_VALIDATE|RGV587|captcha|\/punish\?/i.test(item))) {
- throw new UpstreamResponseError(
- 'Qwen 网页上游触发 WAF/captcha;Agent 上下文可能过大或账号需要验证',
- WAF_CHALLENGE_CODE,
- { ret }
- );
- }
-
const explicitError = payload.error;
if (explicitError && !Array.isArray(payload.choices)) {
const message = typeof explicitError === 'string'
@@ -223,6 +284,8 @@ const assertNoUpstreamFailure = (payload) => {
module.exports = {
UpstreamResponseError,
assertNoUpstreamFailure,
+ detectWafChallenge,
+ isWafChallengeBody,
isRateLimitError,
isWafChallengeError,
rateLimitRetryAfterSeconds,
diff --git a/tests/expected-counts.json b/tests/expected-counts.json
index b070841b..a2a77eac 100644
--- a/tests/expected-counts.json
+++ b/tests/expected-counts.json
@@ -1,5 +1,5 @@
{
- "tests": 1146,
+ "tests": 1152,
"suites": 134,
"note": "Authoritative count. Verify with the per-file sum in AGENTS.md (\"The test gate\"). The -a on that grep is load-bearing: tool-prompt.test.js emits bytes that make grep call the stream binary, and without -a its whole summary line — 133 tests — is silently dropped from the sum.",
"updated": "2026-09-16"
diff --git a/tests/tool-prompt.test.js b/tests/tool-prompt.test.js
index 695fef0e..333e5986 100644
--- a/tests/tool-prompt.test.js
+++ b/tests/tool-prompt.test.js
@@ -816,7 +816,11 @@ test('lockstep: ningun sitio de prompt/hint re-ensena la forma nativa line.replace(/\/\/.*$/, ''))
+ // Bounded by the newline, not by `$`: on a CRLF checkout `$` matches before
+ // the CR, so `.*` backtracked to the CR and left the comment body in `code`
+ // — the rationale comments that legitimately name `` then failed
+ // the scan below.
+ .map(line => line.replace(/\/\/[^\r\n]*/, ''))
.join('\n')
assert.doesNotMatch(code, /<[ \t]*\/?[ \t]*tool_call[ >]/i, `${rel}: cadena con la forma nativa legible por el modelo`)
}
diff --git a/tests/upstream-waf-challenge.test.js b/tests/upstream-waf-challenge.test.js
new file mode 100644
index 00000000..d2c30765
--- /dev/null
+++ b/tests/upstream-waf-challenge.test.js
@@ -0,0 +1,103 @@
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+
+const {
+ assertNoUpstreamFailure,
+ detectWafChallenge,
+ isWafChallengeBody,
+ isWafChallengeError,
+ isRateLimitError
+} = require('../src/utils/upstream-error.js')
+
+// Material real: lo que chat.qwen.ai devolvio en vivo (HTTP 200) cuando el captcha
+// del WAF salto, con el cuerpo exacto que vio el usuario.
+const capturedWafPayload = () => ({
+ ret: ['FAIL_SYS_USER_VALIDATE', 'RGV587_ERROR::SM::哎哟喂,被挤爆啦,请稍后重试'],
+ data: {
+ url: 'https://chat.qwen.ai:443//api/v2/chat/completions/_____tmd_____/punish?x5secdata=xgad0069&x5step=2&action=captchaconnect&pureCaptcha=',
+ dialogSize: { width: '375px', height: '665px' }
+ },
+ dialogSize: { width: '375px', height: '665px' },
+ attributes: { style: 'background-color: transparent' }
+})
+
+test('detectWafChallenge reconoce el paquete real de captcha', () => {
+ const err = detectWafChallenge(capturedWafPayload())
+ assert.ok(err, 'el paquete capturado tiene que reconocerse')
+ assert.equal(err.code, 'upstream_waf_challenge')
+ assert.equal(isWafChallengeError(err), true)
+ // No es cuota agotada: mandarlo como 429 haria que el cliente reintente contra un muro.
+ assert.equal(isRateLimitError(err), false)
+ // El `ret` viaja crudo para que el log diga QUE contesto el upstream.
+ assert.deepEqual(err.details.ret, ['FAIL_SYS_USER_VALIDATE', 'RGV587_ERROR::SM::哎哟喂,被挤爆啦,请稍后重试'])
+})
+
+// Esta es la razon de ser del helper. El camino de texto ya lo reconocia; el de
+// imagen/video no, y como `parseUpstreamImageError` solo miraba `success:false`,
+// el paquete se colaba entero: la generacion "salia bien" y se devolvia la imagen
+// de relleno del propio Qwen (img.alicdn.com) como si fuera el resultado.
+test('assertNoUpstreamFailure lanza con el mismo paquete (camino de texto)', () => {
+ assert.throws(
+ () => assertNoUpstreamFailure(capturedWafPayload()),
+ (e) => e.code === 'upstream_waf_challenge'
+ )
+})
+
+test('una respuesta normal NO se confunde con un captcha', () => {
+ // Canario: si el detector se pasara de listo, todo el trafico bueno saldria como 502.
+ for (const payload of [
+ { choices: [{ message: { content: 'hola' } }] },
+ { data: [{ id: 'qwen3.8-max' }] },
+ { success: true, data: {} },
+ undefined,
+ null,
+ 42
+ ]) {
+ assert.equal(detectWafChallenge(payload), null, `falso positivo con ${JSON.stringify(payload)}`)
+ assert.doesNotThrow(() => assertNoUpstreamFailure(payload))
+ }
+})
+
+test('detecta el challenge por cada señal, no solo por `ret`', () => {
+ // El upstream no siempre usa la misma forma: basta una de las señales.
+ const byCode = detectWafChallenge({ code: 'FAIL_SYS_USER_VALIDATE' })
+ assert.ok(byCode, 'señal en code')
+ const byUrl = detectWafChallenge({ data: { url: 'https://x/_____tmd_____/punish?x5secdata=abc' } })
+ assert.ok(byUrl, 'señal en data.url')
+ const byStringRet = detectWafChallenge({ ret: 'RGV587_ERROR::SM::blah' })
+ assert.ok(byStringRet, 'señal con ret escalar')
+})
+
+// El 2026-09-19 la ruta de imagen recibio la MISMA pagina de captcha pero servida como
+// HTML (16 KB), no como el paquete JSON de arriba. El detector solo miraba el JSON, asi
+// que el HTML pasaba entero: sin lineas `data:`, JSON.parse fallaba, y el extractor de
+// URLs sacaba del propio HTML la imagen de relleno de Qwen y la entregaba como resultado.
+test('reconoce la pagina de captcha cuando llega como HTML', () => {
+ const htmlChallenge = [
+ '',
+ '',
+ '',
+ '',
+ ''
+ ].join('\n')
+
+ assert.equal(isWafChallengeBody(htmlChallenge), true)
+ const err = detectWafChallenge(htmlChallenge)
+ assert.ok(err, 'el HTML tiene que reconocerse')
+ assert.equal(err.code, 'upstream_waf_challenge')
+})
+
+test('un cuerpo normal NO se confunde con la pagina de captcha', () => {
+ // Canario del detector de HTML: texto legitimo puede nombrar "captcha" sin serlo.
+ for (const body of [
+ '{"choices":[{"message":{"content":"hi"}}]}',
+ '',
+ 'hola',
+ 'chat.qwen.ai',
+ 'data: {"choices":[{"delta":{"content":"a captcha is a challenge"}}]}',
+ ''
+ ]) {
+ assert.equal(isWafChallengeBody(body), false, `falso positivo con ${JSON.stringify(body.slice(0, 40))}`)
+ assert.equal(detectWafChallenge(body), null)
+ }
+})