Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions src/controllers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down
77 changes: 73 additions & 4 deletions src/controllers/chat.image.video.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 - 任意负载
Expand Down Expand Up @@ -882,7 +947,7 @@ const readVideoUpstreamResult = async (responseStream) => {

const trimmedRawText = rawText.trim()
if (!upstreamError) {
upstreamError = parseUpstreamImageErrorFromText(trimmedRawText) || parseUpstreamImageError(trimmedRawText)
upstreamError = parseUpstreamErrorFromRawText(trimmedRawText)
}

if (!contentUrl) {
Expand Down Expand Up @@ -969,7 +1034,7 @@ const readImageUpstreamResult = async (responseStream) => {

const trimmedRawText = rawText.trim()
if (!upstreamError) {
upstreamError = parseUpstreamImageErrorFromText(trimmedRawText) || parseUpstreamImageError(trimmedRawText)
upstreamError = parseUpstreamErrorFromRawText(trimmedRawText)
}

if (!contentUrl) {
Expand Down Expand Up @@ -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,
Expand All @@ -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')
Expand Down
99 changes: 81 additions & 18 deletions src/utils/upstream-error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<!doctype html> <meta name="aliyun_waf_aa" ...>`. 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|<title>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';
Expand Down Expand Up @@ -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'
Expand All @@ -223,6 +284,8 @@ const assertNoUpstreamFailure = (payload) => {
module.exports = {
UpstreamResponseError,
assertNoUpstreamFailure,
detectWafChallenge,
isWafChallengeBody,
isRateLimitError,
isWafChallengeError,
rateLimitRetryAfterSeconds,
Expand Down
2 changes: 1 addition & 1 deletion tests/expected-counts.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 5 additions & 1 deletion tests/tool-prompt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,11 @@ test('lockstep: ningun sitio de prompt/hint re-ensena la forma nativa <tool_call
const code = src
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.map(line => 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 `<tool_call>` 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 <tool_call> legible por el modelo`)
}
Expand Down
Loading