Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ SEARCH_INFO_MODE=table
# false: full model list with all variants
SIMPLE_MODEL_MAP=false

# Agent 长上下文保护:当发往 Qwen Web 的 JSON 请求体超过此字节数时,
# 自动将完整工具定义和会话历史上传为文本文档,避免约 128 KiB 的 WAF/captcha 限制。
# Agent long-context protection: externalize the complete tool definitions and history
# as a text document before the Qwen Web request reaches its ~128 KiB WAF/captcha limit.
AGENT_CONTEXT_FILE_THRESHOLD_BYTES=92160

# 附件外置后仍保留在实时请求体中的工具协议与当前回合最大字节数。
# Maximum bytes of tool protocol/current-turn context kept in the live request after externalization.
AGENT_CONTEXT_LIVE_PROMPT_BYTES=49152

# Redis链接(如果使用redis模式,则必填,当redis使用tls时将redis://替换为rediss://)
# Redis URL (required for redis mode; use rediss:// for TLS)
REDIS_URL=
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
node_modules
package-lock.json
.tmp/
mitm_mcp_traffic.db
.env
data/data.json
data
Expand All @@ -10,4 +12,4 @@ pkg_dist/*
pkg_dist/
.idea
/public/dist
.vscode
.vscode
8 changes: 8 additions & 0 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ CACHE_MODE=default # Image cache mode (default/file)
| `LEGACY_REASONING_IN_CONTENT` | Reasoning output format. Default `false` = reasoning goes to a separate `reasoning_content` field; `true` = legacy behavior (`<think>` inside `content`) | `true` or `false` |
| `SIMPLE_MODEL_MAP` | Simplify model mapping, return basic models without variants only | `true` or `false` |
| `MODELS_CACHE_TTL` | Model list cache TTL in seconds; after expiry the next request refreshes it from upstream; `0` = never expires | `3600` |
| `AGENT_CONTEXT_FILE_THRESHOLD_BYTES` | Externalize complete Agent tool definitions and history as a Qwen text document when the request body exceeds this size, avoiding the roughly 128 KiB WAF limit | `92160` (90 KiB) |
| `AGENT_CONTEXT_LIVE_PROMPT_BYTES` | Maximum size of the tool protocol and current turn kept in the live request after context externalization | `49152` (48 KiB) |
| `QWEN_CHAT_PROXY_URL` | Custom Chat API reverse proxy address | `https://your-proxy.com` |
| `QWEN_CLI_PROXY_URL` | Custom CLI API reverse proxy address | `https://your-cli-proxy.com` |
| `PROXY_URL` | Outbound request proxy address, supports HTTP/HTTPS/SOCKS5 | `http://127.0.0.1:7890` |
Expand Down Expand Up @@ -507,6 +509,12 @@ Authorization: Bearer sk-your-api-key
- `assistant.tool_calls` and `role:"tool"` in historical messages automatically fold back in chain, `tool_call_id` precisely associated
- `tool_choice` all four states: `"auto"` / `"required"` / `{type:"function",function:{name:"..."}}` / `"none"`
- When `tool_choice="required"` or specifying function, if no tool call triggered initially, automatically appends strong constraint prompt for retry once
- Automatically retries once when the upstream returns reasoning only, with no visible text or executable tool call, preventing an empty terminal Agent turn
- Treats a clean HTTP EOF from Qwen Web as normal `stop` / `tool_calls`; only actual transport failures such as connection resets become stream errors
- Externalizes complete tool definitions and history through Qwen's official file APIs once the Agent request reaches the safety threshold, while keeping the current turn live to avoid WAF/captcha failures during long tool loops
- Surfaces HTTP-200 WAF/captcha business frames as `upstream_waf_challenge` instead of disguising them as an empty success or a generic 502

> For long-running Agents such as Codex, Claude Code, and OpenClaw, keep the default 90 KiB / 48 KiB thresholds. Lower `AGENT_CONTEXT_FILE_THRESHOLD_BYTES` if your reverse proxy adds a substantial request body overhead.

**Request Example:**

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ CACHE_MODE=default # 图片缓存模式 (default/file)
| `LEGACY_REASONING_IN_CONTENT` | 推理输出格式。默认 `false`=推理走独立的 `reasoning_content` 字段;`true`=旧版行为(`<think>` 并入 `content`) | `true` 或 `false` |
| `SIMPLE_MODEL_MAP` | 简化模型映射,只返回基础模型不包含变体 | `true` 或 `false` |
| `MODELS_CACHE_TTL` | 模型列表缓存有效期(秒),过期后下次请求自动向上游刷新;`0` 表示永不过期 | `3600` |
| `AGENT_CONTEXT_FILE_THRESHOLD_BYTES` | Agent 请求体超过此大小时,将完整工具定义和历史自动外置为 Qwen 文本文档,避免触发约 128 KiB 的 WAF 限制 | `92160`(90 KiB) |
| `AGENT_CONTEXT_LIVE_PROMPT_BYTES` | 上下文外置后,实时请求中保留的工具协议和当前回合最大大小 | `49152`(48 KiB) |
| `QWEN_CHAT_PROXY_URL` | 自定义 Chat API 反代地址 | `https://your-proxy.com` |
| `QWEN_CLI_PROXY_URL` | 自定义 CLI API 反代地址 | `https://your-cli-proxy.com` |
| `PROXY_URL` | 出站请求代理地址,支持 HTTP/HTTPS/SOCKS5 | `http://127.0.0.1:7890` |
Expand Down Expand Up @@ -507,6 +509,12 @@ Authorization: Bearer sk-your-api-key
- 历史消息中的 `assistant.tool_calls` 与 `role:"tool"` 自动折叠回链,`tool_call_id` 精确关联
- `tool_choice` 全四态:`"auto"` / `"required"` / `{type:"function",function:{name:"..."}}` / `"none"`
- `tool_choice="required"` 或指定函数时,若首次未触发工具调用,自动追加强约束提示重试一次
- 当上游只返回思考、没有正文或工具调用时自动补偿重试一次,避免 Agent 收到空结束态而提前停止
- Qwen Web 以干净 HTTP EOF 正常结束时会正确映射为 `stop` / `tool_calls`;只有连接重置等真实传输异常才返回流错误
- Agent 请求体超过安全阈值时,完整工具定义和历史会通过 Qwen 官方文件接口外置,当前回合仍留在实时提示中,避免长工具循环撞上 WAF/captcha
- Qwen 返回 HTTP 200 的 WAF/captcha 业务帧时,会显式返回 `upstream_waf_challenge`,不再伪装为空成功或普通 502

> 对 Codex、Claude Code、OpenClaw 等长时间运行的 Agent,建议保持默认的 90 KiB / 48 KiB 阈值。若反代还会附加较大的请求头或正文,可适当下调 `AGENT_CONTEXT_FILE_THRESHOLD_BYTES`。

**请求示例:**

Expand Down
12 changes: 11 additions & 1 deletion src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,17 @@ const config = {
cliEnabled: process.env.ENABLE_CLI === 'true',
// chat 请求重试配置(运行时可被 web UI 覆盖,见 src/utils/data-persistence.js#loadSettings)
chatRetryCount: Math.max(0, parseInt(process.env.CHAT_RETRY_COUNT, 10) || 1),
chatRetryBackoffMs: Math.max(0, parseInt(process.env.CHAT_RETRY_BACKOFF_MS, 10) || 400)
chatRetryBackoffMs: Math.max(0, parseInt(process.env.CHAT_RETRY_BACKOFF_MS, 10) || 400),
// chat.qwen.ai 的 WAF 会在 JSON 请求体接近 128 KiB 时返回 captcha。
// 提前把 Agent 全量历史外置成文本文档,给协议头和当前回合留出安全余量。
agentContextFileThresholdBytes: Math.max(
32 * 1024,
parseInt(process.env.AGENT_CONTEXT_FILE_THRESHOLD_BYTES, 10) || 90 * 1024
),
agentContextLivePromptBytes: Math.max(
8 * 1024,
parseInt(process.env.AGENT_CONTEXT_LIVE_PROMPT_BYTES, 10) || 48 * 1024
)
}

module.exports = config
144 changes: 113 additions & 31 deletions src/controllers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,24 @@ const {
foldToolMessages,
parseToolCallsFromText,
createToolCallStreamParser,
createNativeToolCallAccumulator
createNativeToolCallAccumulator,
looksLikeUnexecutedToolAction
} = require('../utils/tool-prompt.js');
const { consumeSSEStream } = require('../utils/sse.js');
const { logger } = require('../utils/logger');
const { assertNoUpstreamFailure } = require('../utils/upstream-error.js');
const {
analyzeAnthropicCompatibility,
buildAnthropicCompatibilityHeaders
} = require('./anthropic.compatibility.js');

const mapAnthropicStopReason = (upstreamReason, hasToolCalls, sawDone) => {
const mapAnthropicStopReason = (upstreamReason, hasToolCalls, upstreamCompleted) => {
if (hasToolCalls) return 'tool_use';
if (upstreamReason === 'length' || upstreamReason === 'max_tokens') return 'max_tokens';
if (upstreamReason === 'stop_sequence') return 'stop_sequence';
if (upstreamReason === 'content_filter' || upstreamReason === 'refusal') return 'refusal';
if (upstreamReason === 'stop' || upstreamReason === 'end_turn') return 'end_turn';
if (!upstreamReason && sawDone) return 'end_turn';
if (!upstreamReason && upstreamCompleted) return 'end_turn';
return null;
};

Expand Down Expand Up @@ -331,6 +333,18 @@ const buildRetryHint = (toolChoice) => {
return 'You did not call any tool. You MUST now call exactly one tool using the <tool_call>...</tool_call> format.';
};

const buildEmptyOutputRetryHint = () => [
'Your previous reply produced no visible final answer or executable tool call.',
'Continue the Agent task now. If any action remains, emit the required `<tool_call>` block immediately with no preamble.',
'Only give a normal final answer when the task is actually complete; do not repeat hidden reasoning.'
].join(' ');

const buildMissingToolRetryHint = () => [
'Your previous reply described an action but did not execute any tool call.',
'Perform that action now by emitting the real `<tool_call>` block immediately with no preamble.',
'Do not describe the action again or claim completion without a tool result.'
].join(' ');

/**
* 异步迭代上游 axios 流,按 SSE 段切分回调内部 delta JSON
* @param {object} upstream - axios stream 响应
Expand All @@ -341,7 +355,9 @@ const consumeUpstream = async (upstream, onDelta) => consumeSSEStream(upstream,
const payload = frame.data;
if (!payload || payload.trim() === '[DONE]') return;
if (!isJson(payload)) return;
await onDelta(JSON.parse(payload));
const parsed = JSON.parse(payload);
assertNoUpstreamFailure(parsed);
await onDelta(parsed);
});

/**
Expand Down Expand Up @@ -381,7 +397,10 @@ const writeAnthropicEvent = (res, event, data) => {
* @returns {Promise<void>} 完成 Promise
*/
const handleAnthropicStream = async (res, ctx, upstream) => {
const { message_id, model, hasTools, toolChoice, requestBody, allowedToolNames = [] } = ctx;
const {
message_id, model, hasTools, toolChoice, requestBody, allowedToolNames = [],
sendRequest = sendChatRequest
} = ctx;

res.set({
'Content-Type': 'text/event-stream',
Expand Down Expand Up @@ -411,8 +430,9 @@ const handleAnthropicStream = async (res, ctx, upstream) => {
let promptTokens = 0;
let completionTokens = 0;
let upstreamFinishReason = null;
let upstreamSawDone = false;
let upstreamCompleted = false;
let upstreamEventCount = 0;
let visibleText = '';

const parser = hasTools ? createToolCallStreamParser({ allowedToolNames }) : null;
const nativeToolAccumulator = hasTools
Expand Down Expand Up @@ -475,6 +495,7 @@ const handleAnthropicStream = async (res, ctx, upstream) => {
*/
const emitTextDelta = (text) => {
if (!text) return;
visibleText += text;
if (!textBlockOpen) {
closeThinkingBlockIfOpen();
blockIndex += 1;
Expand Down Expand Up @@ -575,28 +596,51 @@ const handleAnthropicStream = async (res, ctx, upstream) => {
};

const initialStreamResult = await consumeUpstream(upstream, onUpstreamDelta);
upstreamSawDone = initialStreamResult.sawDone;
upstreamCompleted = initialStreamResult.completed;
upstreamEventCount = initialStreamResult.eventCount;

// required 重试
if (
parser &&
!parser.hasEmittedAnyCall() &&
// required 与“只有思考、没有正文/工具调用”共用一次 Agent 补偿重试。
const needsRequiredRetry = !!(
parser && !parser.hasEmittedAnyCall() &&
!nativeToolAccumulator?.hasAny() && requiresToolCall(toolChoice)
);
const needsEmptyOutputRetry = !!(
!visibleText.trim() && !parser?.hasEmittedAnyCall() && !parser?.hasPendingCall() &&
!parser?.hasParseError() && !nativeToolAccumulator?.hasAny() &&
!['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason)
);
const needsMissingToolRetry = !!(
hasTools && looksLikeUnexecutedToolAction(visibleText) &&
!parser?.hasEmittedAnyCall() && !parser?.hasPendingCall() && !parser?.hasParseError() &&
!nativeToolAccumulator?.hasAny() &&
requiresToolCall(toolChoice)
) {
const retryBody = appendRetryHint(requestBody, buildRetryHint(toolChoice));
logger.warning?.('Anthropic 流式: tool_choice=required 首次未触发,重试一次', 'ANTHROPIC');
!['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason)
);
if (needsRequiredRetry || needsEmptyOutputRetry || needsMissingToolRetry) {
const retryBody = appendRetryHint(
requestBody,
needsRequiredRetry
? buildRetryHint(toolChoice)
: (needsMissingToolRetry ? buildMissingToolRetryHint() : buildEmptyOutputRetryHint())
);
logger.warning?.(
needsRequiredRetry
? 'Anthropic 流式: tool_choice=required 首次未触发,重试一次'
: (needsMissingToolRetry
? 'Anthropic Agent 首次响应只描述了动作但未调用工具,补偿重试一次'
: 'Anthropic Agent 首次响应没有正文或工具调用,补偿重试一次'),
'ANTHROPIC'
);
try {
const retryResp = await sendChatRequest(retryBody);
const retryResp = await sendRequest(retryBody);
if (retryResp.status && retryResp.response) {
upstreamFinishReason = null;
const retryResult = await consumeUpstream(retryResp.response, onUpstreamDelta);
upstreamSawDone = retryResult.sawDone;
upstreamCompleted = retryResult.completed;
upstreamEventCount = retryResult.eventCount;
}
} catch (e) {
logger.error('Anthropic 流式重试失败', 'ANTHROPIC', '', e);
if (e.publicMessage) throw e;
}
}

Expand Down Expand Up @@ -629,13 +673,21 @@ const handleAnthropicStream = async (res, ctx, upstream) => {
return;
}

if (!visibleText.trim() && !hasEmittedToolCalls &&
!['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason)) {
closeThinkingBlockIfOpen();
closeTextBlockIfOpen();
writeAnthropicError(res, '上游重试后仍未返回正文或工具调用', 'api_error');
return;
}

closeThinkingBlockIfOpen();
closeTextBlockIfOpen();

const stopReason = mapAnthropicStopReason(
upstreamFinishReason,
hasEmittedToolCalls,
upstreamSawDone
upstreamCompleted
);
if (!stopReason) {
const detail = upstreamEventCount === 0 ? '上游未返回任何 SSE 事件' : '上游流在结束标记前断开';
Expand Down Expand Up @@ -669,15 +721,18 @@ const handleAnthropicStream = async (res, ctx, upstream) => {
* @returns {Promise<void>} 完成 Promise
*/
const handleAnthropicNonStream = async (res, ctx, upstream) => {
const { message_id, model, hasTools, toolChoice, requestBody, allowedToolNames = [] } = ctx;
const {
message_id, model, hasTools, toolChoice, requestBody, allowedToolNames = [],
sendRequest = sendChatRequest
} = ctx;

let thinkingContent = '';
let answerContent = '';
let promptTokens = 0;
let completionTokens = 0;
let webSearchInfo = null;
let upstreamFinishReason = null;
let upstreamSawDone = false;
let upstreamCompleted = false;
let upstreamEventCount = 0;
let nativeToolAccumulator = hasTools
? createNativeToolCallAccumulator({ allowedToolNames })
Expand Down Expand Up @@ -720,10 +775,10 @@ const handleAnthropicNonStream = async (res, ctx, upstream) => {
};

const initialStreamResult = await consumeUpstream(upstream, onUpstreamDelta);
upstreamSawDone = initialStreamResult.sawDone;
upstreamCompleted = initialStreamResult.completed;
upstreamEventCount = initialStreamResult.eventCount;

if (!upstreamSawDone && !upstreamFinishReason) {
if (!upstreamCompleted && !upstreamFinishReason) {
const detail = upstreamEventCount === 0 ? '上游未返回任何 SSE 事件' : '上游流在结束标记前断开';
return res.status(502).json({
type: 'error',
Expand Down Expand Up @@ -757,19 +812,37 @@ const handleAnthropicNonStream = async (res, ctx, upstream) => {
...(nativeToolAccumulator?.getErrors() || [])
];

// required 重试
if (hasTools && toolCalls.length === 0 && requiresToolCall(toolChoice)) {
logger.warning?.('Anthropic 非流式: tool_choice=required 首次未触发,重试一次', 'ANTHROPIC');
// required 与空可见输出共用一次 Agent 补偿重试。
const needsRequiredRetry = hasTools && toolCalls.length === 0 && requiresToolCall(toolChoice);
const needsEmptyOutputRetry = toolCalls.length === 0 && toolErrors.length === 0 && !cleanedText.trim() &&
!['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason);
const needsMissingToolRetry = hasTools && toolCalls.length === 0 && toolErrors.length === 0 &&
looksLikeUnexecutedToolAction(cleanedText) &&
!['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason);
if (needsRequiredRetry || needsEmptyOutputRetry || needsMissingToolRetry) {
logger.warning?.(
needsRequiredRetry
? 'Anthropic 非流式: tool_choice=required 首次未触发,重试一次'
: (needsMissingToolRetry
? 'Anthropic Agent 首次响应只描述了动作但未调用工具,补偿重试一次'
: 'Anthropic Agent 首次响应没有正文或工具调用,补偿重试一次'),
'ANTHROPIC'
);
try {
const retryResp = await sendChatRequest(appendRetryHint(requestBody, buildRetryHint(toolChoice)));
const retryResp = await sendRequest(appendRetryHint(
requestBody,
needsRequiredRetry
? buildRetryHint(toolChoice)
: (needsMissingToolRetry ? buildMissingToolRetryHint() : buildEmptyOutputRetryHint())
));
if (retryResp.status && retryResp.response) {
const before = answerContent;
nativeToolAccumulator = createNativeToolCallAccumulator({ allowedToolNames });
upstreamFinishReason = null;
const retryResult = await consumeUpstream(retryResp.response, onUpstreamDelta);
upstreamSawDone = retryResult.sawDone;
upstreamCompleted = retryResult.completed;
upstreamEventCount = retryResult.eventCount;
if (!upstreamSawDone && !upstreamFinishReason) {
if (!upstreamCompleted && !upstreamFinishReason) {
return res.status(502).json({
type: 'error',
error: { type: 'api_error', message: '工具调用重试流在结束标记前断开' }
Expand All @@ -787,6 +860,7 @@ const handleAnthropicNonStream = async (res, ctx, upstream) => {
}
} catch (e) {
logger.error('Anthropic 非流式重试失败', 'ANTHROPIC', '', e);
if (e.publicMessage) throw e;
}
}

Expand All @@ -800,10 +874,18 @@ const handleAnthropicNonStream = async (res, ctx, upstream) => {
});
}

if (toolCalls.length === 0 && !cleanedText.trim() &&
!['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason)) {
return res.status(502).json({
type: 'error',
error: { type: 'api_error', message: '上游重试后仍未返回正文或工具调用' }
});
}

const stopReason = mapAnthropicStopReason(
upstreamFinishReason,
toolCalls.length > 0,
upstreamSawDone
upstreamCompleted
);
if (!stopReason) {
return res.status(502).json({
Expand Down Expand Up @@ -905,11 +987,11 @@ const handleAnthropicMessages = async (req, res) => {
if (!res.headersSent) {
res.status(500).json({
type: 'error',
error: { type: 'api_error', message: 'Service error' }
error: { type: 'api_error', message: error.publicMessage || 'Service error' }
});
} else {
if (!res.writableEnded) {
try { writeAnthropicError(res, '上游响应处理失败', 'api_error'); } catch (_) { /* ignore */ }
try { writeAnthropicError(res, error.publicMessage || '上游响应处理失败', 'api_error'); } catch (_) { /* ignore */ }
}
}
}
Expand Down
Loading