From 924f11cac59ad0e81bbf559b15ea39d20b381f84 Mon Sep 17 00:00:00 2001 From: white Date: Sat, 19 Sep 2026 12:31:27 +0800 Subject: [PATCH 1/3] fix(ci): repair the lint and CRLF-fragile test gates on main Two independent defects broke the verification workflow on main: 1. `bun run lint` failed with 4x no-useless-assignment in src/controllers/anthropic.js. In handleAnthropicStream and handleAnthropicNonStream, `promptTokens`/`completionTokens` were initialised to 0 and then unconditionally overwritten from the authoritative usage object, so the initialisers were dead stores. Remove the write-only locals and take both values as consts directly from `usage` at the single point where they are resolved. No behavior change: the values consumed by attributeChatUsage and the emitted usage payload are identical. 2. tests/tool-prompt.test.js:805 failed on any CRLF checkout ("ningun sitio de prompt/hint re-ensena la forma nativa "). The per-line comment strip used a line-comment regex anchored with `$`, and on CRLF input the dot stops before the CR so `$` matches there; the match is not global, so the engine backtracked, matched the empty string, and left the comment body in `code`. The rationale comments that legitimately name `` then tripped the source scan. Bound the strip by the newline instead, which is what the test intends on both LF and CRLF checkouts. Verified locally: lint exit 0; frontend build exit 0; test gate PASS 1146 tests / 134 suites / 0 fail (matches tests/expected-counts.json); bun test:bun smoke PASS (8/8). --- src/controllers/anthropic.js | 12 ++++-------- tests/tool-prompt.test.js | 6 +++++- 2 files changed, 9 insertions(+), 9 deletions(-) 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/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`) } From 86e768aa8dfbe5ae6c862d25491f7c5550dc65b8 Mon Sep 17 00:00:00 2001 From: white Date: Sat, 19 Sep 2026 13:16:16 +0800 Subject: [PATCH 2/3] fix(images): resolve upstreamStream before it is used in the image/video path `/v1/images/generations`, `/v1/images/edits` and `/v1/chat/completions` with any image/video chat type all returned HTTP 500: {"error":{"message":"Cannot access 'upstreamStream' before initialization.", "type":"server_error"}} In generateImageVideoResult the flag was read while building the request headers (`accept: upstreamStream ? ... ) but declared with `const` ~15 lines later, so the binding was still in the temporal dead zone. Nothing could reach the upstream: every image request died inside Qwen2API before a socket was opened. Resolve newChatType/upstreamStream before the headers block. The later `reqBody.stream = upstreamStream` assignment moves up with them, so the values consumed downstream (log line, responseType, and the t2i/image_edit branch) are unchanged. `reqBody.messages[0]?.chat_type` is read defensively since this runs before the guard that guarantees messages[0] exists. Found while wiring the image path to a live account; verified the endpoint then proceeds to the upstream call instead of throwing. --- src/controllers/chat.image.video.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/controllers/chat.image.video.js b/src/controllers/chat.image.video.js index 756e49c6..33966ecf 100644 --- a/src/controllers/chat.image.video.js +++ b/src/controllers/chat.image.video.js @@ -1328,6 +1328,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 +1350,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') From 48e8bb3bc3bd88a75122eda469a71b2aa5606b07 Mon Sep 17 00:00:00 2001 From: white Date: Sat, 19 Sep 2026 13:33:12 +0800 Subject: [PATCH 3/3] fix(images): stop treating the upstream WAF/captcha challenge as a successful generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Qwen challenges the account, the image/video path received the challenge **as a successful generation**. Two forms of the same challenge, both missed: **JSON form** — HTTP 200 with Qwen's own packet, not an HTTP error: {"ret":["FAIL_SYS_USER_VALIDATE","RGV587_ERROR::SM::哎哟喂,被挤爆啦,请稍后重试"], "data":{"url":"https://chat.qwen.ai:443//api/v2/chat/completions/_____tmd_____/punish?x5secdata=..."}} `src/utils/upstream-error.js` already recognised that payload — `assertNoUpstreamFailure` tests exactly these signals — but only the text controllers go through it. The image path asks `parseUpstreamImageError`, which accepted only the unrelated `{success:false, data:{code}}` business-error shape and returned `null` for everything else. **HTML form** — and this is the one that actually bit in the live session: the same challenge arrived as 16 KB of Aliyun WAF page rather than the JSON packet. Neither existing reader could see it: `parseSsePayloads` finds no `data:` lines, and `JSON.parse` throws. So the body fell through to `extractResourceUrlFromPayload`, which pulled *the first URL inside the captcha page* — Qwen's own fallback artwork, `img.alicdn.com/...tps-900-594.png` — and `/v1/images/generations` answered `200` with it in `data[0].url`, in under a second. The caller had no way to tell that nothing was generated. That is the part that hurts: silently wrong data beats a loud failure only in appearance. ### Fix The detection moves to its single owner instead of being copied per path: - `findWafChallengeSignals(payload)` / `detectWafChallenge(payload)` / `isWafChallengeBody(raw)` live in `upstream-error.js` next to `WAF_CHALLENGE_CODE`; `assertNoUpstreamFailure` now calls the helper, so text and image paths classify from one implementation and cannot drift. - `detectWafChallenge` handles both forms: the JSON packet (any of `ret` / `code` / `data.code` / `data.url` / `error.code`) and the raw HTML page, via markers that exist only in the challenge (`aliyun_waf_`, `aliyunCaptcha`, `_waf_is_mobile`, `id="captcha-element"`). Anchoring on those rather than the bare word "captcha" is deliberate: model prose can name a captcha without being one, and a false positive would 502 healthy traffic. - `parseUpstreamImageError` consults it first and returns the canonical `upstream_waf_challenge` error, which the existing plumbing already propagates (`readImageUpstreamResult` -> `throw upstreamError` -> 502). The placeholder URL is never read, because the body is classified before any URL extraction. - The stream-end fallback also could not see the packet: it tried `JSON.parse(entireText)` then the SSE parser, but the non-streaming upstream wraps its JSON behind a `data:` prefix. `parseUpstreamErrorFromRawText` reuses the same `parseSsePayloads` the streaming path uses, and checks the HTML form first. ### Verified against the live upstream Before: `HTTP 200 {"created":...,"data":[{"url":"https://img.alicdn.com/imgextra/i1/O1CN01L12MaQ...png"}]}` in 0.7 s. After: `HTTP 502 {"error":{"message":"Qwen 网页上游触发 WAF/captcha;Agent 上下文可能过大或账号需要验证","type":"server_error"}}`, with the server log naming `code: "upstream_waf_challenge"` and the `aliyun_waf` preview. Tests: tests/upstream-waf-challenge.test.js (6 cases) built from the payloads captured live, including canaries that normal bodies — `{choices:[...]}`, `{data:[...]}`, `{success:true}`, a real image markdown line, a plain `` page, `null`, `42` — never classify as a challenge. `bun run lint` clean; test gate PASS 1152 tests / 134 suites / 0 fail (expected-counts bumped 1146 -> 1152 in the same commit). Scope: the use-before-initialization crash fixed in the first commit of this branch is what made the image path reachable at all; this commit is about what it does once reachable. I have **not** observed a real image come back from a healthy account — the account used for testing is itself the one being challenged, so a 502 is the correct answer for it and is not evidence that generation succeeds elsewhere. --- src/controllers/chat.image.video.js | 69 +++++++++++++++++- src/utils/upstream-error.js | 99 ++++++++++++++++++++----- tests/expected-counts.json | 2 +- tests/upstream-waf-challenge.test.js | 103 +++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 tests/upstream-waf-challenge.test.js diff --git a/src/controllers/chat.image.video.js b/src/controllers/chat.image.video.js index 33966ecf..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) { 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/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 = [ + '<!doctype html>', + '<meta charset="UTF-8">', + '<meta name="aliyun_waf_aa" content="ff926c7f07e45e2e487a29a6197d3460">', + '<meta name="aliyun_waf_bb" content="eade71455e2ad9c6d08b82bc7d98df8c">', + '<title>' + ].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"}}]}', + '![image](https://img.alicdn.com/imgextra/i1/real.png)', + '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) + } +})