diff --git a/.env.example b/.env.example index 1108cbb..ecf6e99 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index d3a4722..470fa53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules package-lock.json +.tmp/ +mitm_mcp_traffic.db .env data/data.json data @@ -10,4 +12,4 @@ pkg_dist/* pkg_dist/ .idea /public/dist -.vscode \ No newline at end of file +.vscode diff --git a/README-en.md b/README-en.md index 60891e1..81f4303 100644 --- a/README-en.md +++ b/README-en.md @@ -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 (`` 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` | @@ -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:** diff --git a/README.md b/README.md index 265073a..10b31d5 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ CACHE_MODE=default # 图片缓存模式 (default/file) | `LEGACY_REASONING_IN_CONTENT` | 推理输出格式。默认 `false`=推理走独立的 `reasoning_content` 字段;`true`=旧版行为(`` 并入 `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` | @@ -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`。 **请求示例:** diff --git a/src/config/index.js b/src/config/index.js index ae75099..71797e9 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -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 diff --git a/src/controllers/anthropic.js b/src/controllers/anthropic.js index 12b0b64..0300410 100644 --- a/src/controllers/anthropic.js +++ b/src/controllers/anthropic.js @@ -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; }; @@ -331,6 +333,18 @@ const buildRetryHint = (toolChoice) => { return 'You did not call any tool. You MUST now call exactly one tool using the ... 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 `` 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 `` 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 响应 @@ -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); }); /** @@ -381,7 +397,10 @@ const writeAnthropicEvent = (res, event, data) => { * @returns {Promise} 完成 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', @@ -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 @@ -475,6 +495,7 @@ const handleAnthropicStream = async (res, ctx, upstream) => { */ const emitTextDelta = (text) => { if (!text) return; + visibleText += text; if (!textBlockOpen) { closeThinkingBlockIfOpen(); blockIndex += 1; @@ -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; } } @@ -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 事件' : '上游流在结束标记前断开'; @@ -669,7 +721,10 @@ const handleAnthropicStream = async (res, ctx, upstream) => { * @returns {Promise} 完成 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 = ''; @@ -677,7 +732,7 @@ const handleAnthropicNonStream = async (res, ctx, upstream) => { let completionTokens = 0; let webSearchInfo = null; let upstreamFinishReason = null; - let upstreamSawDone = false; + let upstreamCompleted = false; let upstreamEventCount = 0; let nativeToolAccumulator = hasTools ? createNativeToolCallAccumulator({ allowedToolNames }) @@ -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', @@ -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: '工具调用重试流在结束标记前断开' } @@ -787,6 +860,7 @@ const handleAnthropicNonStream = async (res, ctx, upstream) => { } } catch (e) { logger.error('Anthropic 非流式重试失败', 'ANTHROPIC', '', e); + if (e.publicMessage) throw e; } } @@ -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({ @@ -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 */ } } } } diff --git a/src/controllers/chat.js b/src/controllers/chat.js index bb6c757..d9c2bfb 100644 --- a/src/controllers/chat.js +++ b/src/controllers/chat.js @@ -4,15 +4,17 @@ const { sendChatRequest } = require('../utils/request.js') const { createToolCallStreamParser, parseToolCallsFromText, - createNativeToolCallAccumulator + createNativeToolCallAccumulator, + looksLikeUnexecutedToolAction } = require('../utils/tool-prompt.js') const { consumeSSEStream } = require('../utils/sse.js') const accountManager = require('../utils/account.js') const config = require('../config/index.js') const { logger } = require('../utils/logger') const { createUpstreamDeltaNormalizer } = require('../utils/chat-helpers.js') +const { assertNoUpstreamFailure } = require('../utils/upstream-error.js') -const normalizeOpenAIFinishReason = (upstreamReason, hasToolCalls, sawDone) => { +const normalizeOpenAIFinishReason = (upstreamReason, hasToolCalls, upstreamCompleted) => { if (hasToolCalls) return 'tool_calls' if (typeof upstreamReason === 'string' && upstreamReason.length > 0) { const aliases = { @@ -24,7 +26,7 @@ const normalizeOpenAIFinishReason = (upstreamReason, hasToolCalls, sawDone) => { const supported = new Set(['stop', 'length', 'tool_calls', 'content_filter', 'function_call']) return supported.has(normalized) ? normalized : null } - return sawDone ? 'stop' : null + return upstreamCompleted ? 'stop' : null } const writeOpenAIStreamError = (res, message, code = 'upstream_incomplete') => { @@ -101,6 +103,18 @@ const buildRequiredRetryHint = (toolChoice) => { return 'You did not call any tool in your previous reply. You MUST now call exactly one tool using the ... format and nothing else.' } +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 `` 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 `` block immediately with no preamble.', + 'Do not describe the action again or claim completion without a tool result.' +].join(' ') + const appendRetryHintToRequestBody = (requestBody, hint) => { const messages = Array.isArray(requestBody?.messages) ? requestBody.messages.map(message => ({ ...message })) @@ -162,6 +176,7 @@ const handleStreamResponse = async (res, response, enable_thinking, enable_web_s let pendingImageMarkdownList = [] const hasTools = !!options.has_tools + const requestSender = options.sendChatRequest || sendChatRequest const toolChoice = options.tool_choice const allowedToolNames = options.allowed_tool_names || [] const toolParser = hasTools ? createToolCallStreamParser({ allowedToolNames }) : null @@ -169,7 +184,7 @@ const handleStreamResponse = async (res, response, enable_thinking, enable_web_s ? createNativeToolCallAccumulator({ allowedToolNames }) : null let upstreamFinishReason = null - let upstreamSawDone = false + let upstreamCompleted = false let upstreamEventCount = 0 // Token消耗量统计 @@ -179,6 +194,7 @@ const handleStreamResponse = async (res, response, enable_thinking, enable_web_s total_tokens: 0 } let completionContent = '' // 收集完整的回复内容用于token估算 + let visibleContent = '' // 提取prompt文本用于token估算 let promptText = '' @@ -199,6 +215,7 @@ const handleStreamResponse = async (res, response, enable_thinking, enable_web_s */ const writeContentDelta = (text) => { if (!text) return + visibleContent += text res.write(`data: ${JSON.stringify({ "id": `chatcmpl-${message_id}`, "object": "chat.completion.chunk", @@ -319,6 +336,7 @@ const handleStreamResponse = async (res, response, enable_thinking, enable_web_s const processSSEPayload = async (dataContent) => { const decodeJson = isJson(dataContent) ? JSON.parse(dataContent) : null if (decodeJson === null) return + assertNoUpstreamFailure(decodeJson) if (decodeJson.usage) { totalTokens = { @@ -454,31 +472,56 @@ const handleStreamResponse = async (res, response, enable_thinking, enable_web_s throw error } }) - upstreamSawDone = result.sawDone + upstreamCompleted = result.completed upstreamEventCount = result.eventCount } await pipeUpstream(response) - // tool_choice="required" 强校验:未触发任何工具调用则追加更强提示重试一次 - if ( - hasTools && - toolParser && + // Agent 空回合补偿:只有思考、没有正文/工具调用时自动重试一次。 + // required 仍使用更强的指定工具提示;两个条件共用一次重试,避免重复请求。 + const needsRequiredRetry = !!( + hasTools && toolParser && !toolParser.hasEmittedAnyCall() && !nativeToolAccumulator?.hasAny() && requiresToolCall(toolChoice) - ) { - const retryHint = buildRequiredRetryHint(toolChoice) + ) + const needsEmptyOutputRetry = !!( + !visibleContent.trim() && + !toolParser?.hasEmittedAnyCall() && + !toolParser?.hasPendingCall() && + !toolParser?.hasParseError() && + !nativeToolAccumulator?.hasAny() && + !['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason) + ) + const needsMissingToolRetry = !!( + hasTools && looksLikeUnexecutedToolAction(visibleContent) && + !toolParser?.hasEmittedAnyCall() && !toolParser?.hasPendingCall() && + !toolParser?.hasParseError() && !nativeToolAccumulator?.hasAny() && + !['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason) + ) + if (needsRequiredRetry || needsEmptyOutputRetry || needsMissingToolRetry) { + const retryHint = needsRequiredRetry + ? buildRequiredRetryHint(toolChoice) + : (needsMissingToolRetry ? buildMissingToolRetryHint() : buildEmptyOutputRetryHint()) const retryBody = appendRetryHintToRequestBody(requestBody, retryHint) - logger.warning?.('tool_choice=required 首次未触发工具调用,进行一次重试', 'CHAT') + logger.warning?.( + needsRequiredRetry + ? 'tool_choice=required 首次未触发工具调用,进行一次重试' + : (needsMissingToolRetry + ? 'Agent 首次响应只描述了动作但未调用工具,进行一次补偿重试' + : 'Agent 首次响应没有正文或工具调用,进行一次补偿重试'), + 'CHAT' + ) try { - const retryResp = await sendChatRequest(retryBody) + const retryResp = await requestSender(retryBody) if (retryResp.status && retryResp.response) { upstreamFinishReason = null await pipeUpstream(retryResp.response) } } catch (e) { - logger.error('required 模式重试失败', 'CHAT', '', e) + logger.error('Agent 补偿重试失败', 'CHAT', '', e) + if (e.publicMessage) throw e } } @@ -509,10 +552,16 @@ const handleStreamResponse = async (res, response, enable_thinking, enable_web_s return } + if (!visibleContent.trim() && !hasEmittedToolCalls && + !['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason)) { + writeOpenAIStreamError(res, '上游重试后仍未返回正文或工具调用', 'upstream_empty_output') + return + } + const finishReason = normalizeOpenAIFinishReason( upstreamFinishReason, hasEmittedToolCalls, - upstreamSawDone + upstreamCompleted ) if (!finishReason) { const detail = upstreamEventCount === 0 ? '上游未返回任何 SSE 事件' : '上游流在结束标记前断开' @@ -575,14 +624,18 @@ const handleStreamResponse = async (res, response, enable_thinking, enable_web_s logger.error('聊天处理错误', 'CHAT', '', error) if (res.headersSent) { if (!res.writableEnded) { - writeOpenAIStreamError(res, '上游流式传输失败', 'upstream_stream_error') + writeOpenAIStreamError( + res, + error.publicMessage || '上游流式传输失败', + error.publicMessage ? error.code : 'upstream_stream_error' + ) } } else { res.status(502).json({ error: { - message: '上游流式传输失败', + message: error.publicMessage || '上游流式传输失败', type: 'upstream_stream_error', - code: 'upstream_stream_error' + code: error.code || 'upstream_stream_error' } }) } @@ -612,13 +665,14 @@ const handleNonStreamResponse = async (res, response, enable_thinking, enable_we let pendingImageMarkdownList = [] const hasTools = !!options.has_tools + const requestSender = options.sendChatRequest || sendChatRequest const toolChoice = options.tool_choice const allowedToolNames = options.allowed_tool_names || [] let nativeToolAccumulator = hasTools ? createNativeToolCallAccumulator({ allowedToolNames }) : null let upstreamFinishReason = null - let upstreamSawDone = false + let upstreamCompleted = false let upstreamEventCount = 0 // Token消耗量统计 @@ -649,6 +703,7 @@ const handleNonStreamResponse = async (res, response, enable_thinking, enable_we const processAccumulatedPayload = async (dataContent) => { const decodeJson = isJson(dataContent) ? JSON.parse(dataContent) : null if (decodeJson === null) return + assertNoUpstreamFailure(decodeJson) if (decodeJson.usage) { totalTokens = { @@ -739,13 +794,13 @@ const handleNonStreamResponse = async (res, response, enable_thinking, enable_we if (!frame.data || frame.data.trim() === '[DONE]') return await processAccumulatedPayload(frame.data) }) - upstreamSawDone = result.sawDone + upstreamCompleted = result.completed upstreamEventCount = result.eventCount } await accumulateUpstream(response) - if (!upstreamSawDone && !upstreamFinishReason) { + if (!upstreamCompleted && !upstreamFinishReason) { const detail = upstreamEventCount === 0 ? '上游未返回任何 SSE 事件' : '上游流在结束标记前断开' return res.status(502).json({ error: { message: detail, type: 'upstream_stream_error', code: 'upstream_incomplete' } @@ -767,19 +822,34 @@ const handleNonStreamResponse = async (res, response, enable_thinking, enable_we ] } - // tool_choice="required" 强校验:未触发则重试一次 - if (hasTools && toolCalls.length === 0 && requiresToolCall(toolChoice)) { - const retryHint = buildRequiredRetryHint(toolChoice) + // required 未调用,或只有思考没有可见输出时,共用一次补偿重试。 + const needsRequiredRetry = hasTools && toolCalls.length === 0 && requiresToolCall(toolChoice) + const needsEmptyOutputRetry = toolCalls.length === 0 && toolErrors.length === 0 && !assistantContent.trim() && + !['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason) + const needsMissingToolRetry = hasTools && toolCalls.length === 0 && toolErrors.length === 0 && + looksLikeUnexecutedToolAction(assistantContent) && + !['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason) + if (needsRequiredRetry || needsEmptyOutputRetry || needsMissingToolRetry) { + const retryHint = needsRequiredRetry + ? buildRequiredRetryHint(toolChoice) + : (needsMissingToolRetry ? buildMissingToolRetryHint() : buildEmptyOutputRetryHint()) const retryBody = appendRetryHintToRequestBody(requestBody, retryHint) - logger.warning?.('tool_choice=required 首次未触发工具调用,进行一次重试', 'CHAT') + logger.warning?.( + needsRequiredRetry + ? 'tool_choice=required 首次未触发工具调用,进行一次重试' + : (needsMissingToolRetry + ? 'Agent 首次响应只描述了动作但未调用工具,进行一次补偿重试' + : 'Agent 首次响应没有正文或工具调用,进行一次补偿重试'), + 'CHAT' + ) try { - const retryResp = await sendChatRequest(retryBody) + const retryResp = await requestSender(retryBody) if (retryResp.status && retryResp.response) { const before = fullContent nativeToolAccumulator = createNativeToolCallAccumulator({ allowedToolNames }) upstreamFinishReason = null await accumulateUpstream(retryResp.response) - if (!upstreamSawDone && !upstreamFinishReason) { + if (!upstreamCompleted && !upstreamFinishReason) { return res.status(502).json({ error: { message: '工具调用重试流在结束标记前断开', @@ -802,7 +872,8 @@ const handleNonStreamResponse = async (res, response, enable_thinking, enable_we ] } } catch (e) { - logger.error('required 模式重试失败', 'CHAT', '', e) + logger.error('Agent 补偿重试失败', 'CHAT', '', e) + if (e.publicMessage) throw e } } @@ -817,10 +888,21 @@ const handleNonStreamResponse = async (res, response, enable_thinking, enable_we }) } + if (toolCalls.length === 0 && !assistantContent.trim() && + !['length', 'max_tokens', 'content_filter', 'refusal'].includes(upstreamFinishReason)) { + return res.status(502).json({ + error: { + message: '上游重试后仍未返回正文或工具调用', + type: 'upstream_empty_output', + code: 'upstream_empty_output' + } + }) + } + const finishReason = normalizeOpenAIFinishReason( upstreamFinishReason, toolCalls.length > 0, - upstreamSawDone + upstreamCompleted ) if (!finishReason) { return res.status(502).json({ @@ -884,9 +966,9 @@ const handleNonStreamResponse = async (res, response, enable_thinking, enable_we if (!res.headersSent) { res.status(502).json({ error: { - message: '上游响应处理失败', + message: error.publicMessage || '上游响应处理失败', type: 'upstream_error', - code: 'upstream_error' + code: error.code || 'upstream_error' } }) } diff --git a/src/utils/request.js b/src/utils/request.js index 901cc31..3f0023b 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -5,6 +5,7 @@ const { logger } = require('./logger') const { getSsxmodItna, getSsxmodItna2 } = require('./ssxmod-manager') const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('./proxy-helper') const { generateUUID, getTimezoneHeader } = require('./tools.js') +const { uploadAgentContextFile } = require('./upload.js') // 传输层(非 HTTP)错误码 — 这些重试的, HTTP 响应不重试 const RETRYABLE_ERROR_CODES = new Set([ @@ -26,6 +27,160 @@ const isRetryableNetworkError = (error) => { const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)) +const HISTORY_MARKER = '# Conversation history (JSONL)' +const CURRENT_MESSAGE_MARKER = '# Current message' + +const byteLength = (value) => Buffer.byteLength(String(value || ''), 'utf8') + +const truncateUtf8 = (value, maxBytes, fromEnd = false) => { + const buffer = Buffer.from(String(value || ''), 'utf8') + if (buffer.length <= maxBytes) return buffer.toString('utf8') + const slice = fromEnd + ? buffer.subarray(Math.max(0, buffer.length - maxBytes)) + : buffer.subarray(0, maxBytes) + return slice.toString('utf8').replace(/^\uFFFD|\uFFFD$/g, '') +} + +const getMessageTextContent = (message) => { + if (typeof message?.content === 'string') return message.content + if (!Array.isArray(message?.content)) return null + const textPart = message.content.find(item => + item && typeof item.text === 'string' && + (item.text.includes(HISTORY_MARKER) || item.text.includes(CURRENT_MESSAGE_MARKER)) + ) || message.content.find(item => item && typeof item.text === 'string') + return textPart?.text ?? null +} + +const replaceMessageTextContent = (message, text) => { + if (typeof message?.content === 'string') return { ...message, content: text } + if (!Array.isArray(message?.content)) return message + + let replaced = false + const content = message.content.map(item => { + const isPreferred = item && typeof item.text === 'string' && + (item.text.includes(HISTORY_MARKER) || item.text.includes(CURRENT_MESSAGE_MARKER)) + if (!replaced && isPreferred) { + replaced = true + return { ...item, text } + } + return item + }) + if (!replaced) { + const fallbackIndex = content.findIndex(item => item && typeof item.text === 'string') + if (fallbackIndex >= 0) { + content[fallbackIndex] = { ...content[fallbackIndex], text } + } + } + return { ...message, content } +} + +/** + * 附件外置后仍留在 HTTP 请求体中的高优先级提示。 + * 优先保留工具协议与当前回合;完整原文始终存在附件中。 + */ +const buildAgentContextLivePrompt = ( + original, + maxBytes = config.agentContextLivePromptBytes, + attachmentName = 'QWEN2API_AGENT_CONTEXT.txt' +) => { + const text = String(original || '') + const historyIndex = text.indexOf(HISTORY_MARKER) + const currentIndex = text.lastIndexOf(CURRENT_MESSAGE_MARKER) + const prefix = historyIndex >= 0 ? text.slice(0, historyIndex).trim() : '' + const current = currentIndex >= 0 ? text.slice(currentIndex).trim() : '' + const notice = [ + '# Agent context attachment', + `The complete system instructions, tool schemas, conversation history and current task are attached as ${attachmentName}.`, + 'Read that attachment as authoritative context before acting. Continue from the latest state; do not restart the task or claim completion without verification.', + 'When an available tool is needed, emit the real `` block immediately. Do not replace it with prose such as “I will run...” or “done”.' + ].join('\n') + + let live = [notice, prefix, current].filter(Boolean).join('\n\n') + if (byteLength(live) <= maxBytes) return live + + const reserved = byteLength(notice) + 4 + const remaining = Math.max(1024, maxBytes - reserved) + const currentBudget = Math.max(1024, Math.floor(remaining * 0.45)) + const prefixBudget = Math.max(1024, remaining - currentBudget) + live = [ + notice, + prefix ? truncateUtf8(prefix, prefixBudget) : '', + current ? truncateUtf8(current, currentBudget, true) : '' + ].filter(Boolean).join('\n\n') + return live +} + +const compactAgentContextFallback = (original, maxBytes = config.agentContextLivePromptBytes) => { + const text = String(original || '') + const notice = [ + '# Agent context recovery', + 'The upstream document attachment failed, so older context was compacted to stay below the Qwen Web request limit.', + 'Continue the latest Agent task using the recent context below. Use a real tool call whenever more work is required; do not report completion before verification.' + ].join('\n') + const limit = Math.max(1024, Number(maxBytes) || config.agentContextLivePromptBytes) + const historyIndex = text.indexOf(HISTORY_MARKER) + const prefix = historyIndex >= 0 ? text.slice(0, historyIndex).trim() : '' + const recent = historyIndex >= 0 ? text.slice(historyIndex).trim() : text + const remaining = Math.max(256, limit - byteLength(notice) - 4) + const prefixBudget = prefix ? Math.floor(remaining * 0.55) : 0 + const recentBudget = remaining - prefixBudget + return [ + notice, + prefix ? truncateUtf8(prefix, prefixBudget) : '', + truncateUtf8(recent, recentBudget, true) + ].filter(Boolean).join('\n\n') +} + +/** + * 超过安全阈值时把完整 Agent 上下文上传为 Qwen 文档。 + * uploader 可注入,便于在无真实账号的测试环境验证整个变换。 + */ +const externalizeOversizedAgentContext = async ( + payload, + currentToken, + currentAccount, + options = {} +) => { + const thresholdBytes = Math.max(1024, Number(options.thresholdBytes) || config.agentContextFileThresholdBytes) + const serializedBytes = byteLength(JSON.stringify(payload)) + const message = payload?.messages?.[0] + const originalContent = getMessageTextContent(message) + if (serializedBytes <= thresholdBytes || !message || originalContent === null) { + return { payload, externalized: false, serializedBytes } + } + + const uploader = options.uploader || uploadAgentContextFile + let file + try { + file = await uploader(originalContent, currentToken, currentAccount, options) + } catch (error) { + logger.error('Agent 长上下文附件上传/解析失败,回退到最近上下文', 'REQUEST', '', error) + const fallbackMessage = replaceMessageTextContent( + message, + compactAgentContextFallback(originalContent, options.livePromptBytes) + ) + fallbackMessage.files = Array.isArray(message.files) ? [...message.files] : [] + return { + payload: { ...payload, messages: [fallbackMessage, ...payload.messages.slice(1)] }, + externalized: false, + compacted: true, + serializedBytes + } + } + + const attachmentName = file?.name || file?.file?.filename || 'QWEN2API_AGENT_CONTEXT.txt' + const externalizedMessage = replaceMessageTextContent( + message, + buildAgentContextLivePrompt(originalContent, options.livePromptBytes, attachmentName) + ) + externalizedMessage.files = [...(Array.isArray(message.files) ? message.files : []), file] + return { + payload: { ...payload, messages: [externalizedMessage, ...payload.messages.slice(1)] }, + externalized: true, + serializedBytes + } +} + /** * 发送聊天请求 * @param {Object} body - 请求体 @@ -90,7 +245,7 @@ const sendChatRequest = async (body) => { requestConfig.headers.referer = `${chatBaseUrl}/c/${chat_id}` const url = `${chatBaseUrl}/api/v2/chat/completions?chat_id=` + chat_id // 对齐网页双写 chatId/parentId(FE 0.2.81) - const payload = { + const rawPayload = { ...body, stream: true, chat_id, @@ -98,6 +253,17 @@ const sendChatRequest = async (body) => { parent_id: body.parent_id ?? null, parentId: body.parentId ?? body.parent_id ?? null } + const contextResult = await externalizeOversizedAgentContext( + rawPayload, + currentToken, + currentAccount + ) + const payload = contextResult.payload + if (contextResult.externalized) { + logger.info(`Agent 上下文已外置为 Qwen 文档(原请求 ${contextResult.serializedBytes} bytes)`, 'REQUEST', '📎') + } else if (contextResult.compacted) { + logger.warn(`Agent 上下文附件失败,已保留最近上下文(原请求 ${contextResult.serializedBytes} bytes)`, 'REQUEST') + } const maxRetries = Math.max(0, parseInt(config.chatRetryCount, 10) || 0) const backoffMs = Math.max(0, parseInt(config.chatRetryBackoffMs, 10) || 0) @@ -238,5 +404,8 @@ const generateChatID = async (currentToken, model, account, chatType = 't2t') => module.exports = { sendChatRequest, - generateChatID -} \ No newline at end of file + generateChatID, + buildAgentContextLivePrompt, + compactAgentContextFallback, + externalizeOversizedAgentContext +} diff --git a/src/utils/sse.js b/src/utils/sse.js index d120928..e01d2fd 100644 --- a/src/utils/sse.js +++ b/src/utils/sse.js @@ -39,7 +39,20 @@ const parseSSEFrame = (rawFrame) => { } } - if (!hasField) return null + if (!hasField) { + // Qwen 的 WAF/业务失败偶尔以 HTTP 200 + 裸 JSON 返回,而不是 SSE data 字段。 + // 将完整 JSON 作为一个 data frame 交给上层分类,避免被当成“0 个事件的正常 EOF”。 + const trimmed = rawFrame.trim() + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + JSON.parse(trimmed) + return { event: null, data: trimmed, id: null, retry: null, raw: rawFrame } + } catch (_) { + // 非完整 JSON 继续按无效 SSE 忽略。 + } + } + return null + } return { event, data: dataLines.join('\n'), id, retry, raw: rawFrame } } @@ -119,7 +132,10 @@ const formatSSEFrame = (frame = {}) => { * 串行消费 Node readable 中的 SSE。for-await 会等待 onFrame,避免 end 事件越过异步 data handler。 * @param {AsyncIterable} stream * @param {(frame: ReturnType) => Promise|void} onFrame - * @returns {Promise<{sawDone: boolean, eventCount: number}>} + * 正常迭代结束代表 HTTP 响应体被完整消费。真正的连接重置、premature close + * 或解压失败会由 for-await 抛出,不能把“没有 [DONE]”等同于传输中断: + * Qwen 网页端的正常流本来就可能以干净 EOF 收尾。 + * @returns {Promise<{sawDone: boolean, eventCount: number, completed: boolean}>} */ const consumeSSEStream = async (stream, onFrame) => { if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') { @@ -143,7 +159,7 @@ const consumeSSEStream = async (stream, onFrame) => { } await consumeFrames(decoder.end()) - return { sawDone, eventCount } + return { sawDone, eventCount, completed: true } } module.exports = { diff --git a/src/utils/tool-prompt.js b/src/utils/tool-prompt.js index 879b0ce..7ec3f67 100644 --- a/src/utils/tool-prompt.js +++ b/src/utils/tool-prompt.js @@ -31,6 +31,23 @@ const serializeToolArguments = (args) => { return JSON.stringify(args ?? {}); }; +const compactDescription = (value, maxLength = 320) => { + const text = String(value || '').replace(/\s+/g, ' ').trim(); + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 1)}…`; +}; + +/** + * 识别模型用“我将执行/Let me inspect”代替真实工具调用的占位回复。 + * 仅匹配明确的动作动词,避免把普通解释或建议误判成工具回合。 + */ +const looksLikeUnexecutedToolAction = (value) => { + const text = String(value || '').trim().replace(/^[#>*\-\s]+/, ''); + const english = /^(?:i(?:['’]ll| will)|let me|i need to|next,?\s+i(?:['’]ll| will))\s+(?:now\s+)?(?:run|execute|check|inspect|read|edit|write|search|open|call|use|look|test|verify|build|deploy|create|update|fetch)\b/i; + const chinese = /^(?:我(?:将|会|先|需要|正在)|让我|接下来(?:我)?(?:将|会|先)?|现在(?:我)?(?:将|会|先|来)?|下面(?:我)?(?:将|会|先)?|正在)(?:立即|马上|先|来)?(?:运行|执行|检查|查看|读取|编辑|修改|写入|搜索|打开|调用|使用|测试|验证|构建|部署|创建|更新|获取)/; + return english.test(text) || chinese.test(text); +}; + const createToolCallObject = (payload, index = 0, id = null) => ({ index, id: id || `call_${generateUUID().replace(/-/g, '').slice(0, 24)}`, @@ -69,7 +86,8 @@ const compressSchemaType = (schema) => { const requiredKeys = new Set(Array.isArray(schema.required) ? schema.required : []); const fields = Object.entries(schema.properties).map(([key, value]) => { const optional = requiredKeys.has(key) ? '' : '?'; - return `${key}${optional}: ${compressSchemaType(value)}`; + const description = compactDescription(value?.description, 180); + return `${key}${optional}: ${compressSchemaType(value)}${description ? ` /* ${description.replace(/\*\//g, '* /')} */` : ''}`; }); return `{ ${fields.join('; ')} }`; } @@ -89,7 +107,7 @@ const compressSchemaType = (schema) => { const compressToolDefinition = (tool) => { const fn = tool?.function || tool; const name = fn?.name || 'unknown'; - const description = (fn?.description || '').trim(); + const description = compactDescription(fn?.description); const params = fn?.parameters || { type: 'object', properties: {} }; const signature = compressSchemaType(params); @@ -119,7 +137,7 @@ const buildToolSystemPrompt = (tools, options = {}) => { const lines = [ '# Tools', '', - 'You have access to the following tools. When a tool call is needed, output a `` block exactly as shown below.', + 'You have access to the following tools. This is an Agent tool protocol, not a suggestion.', '', '## Available tools', compressed, @@ -138,12 +156,15 @@ const buildToolSystemPrompt = (tools, options = {}) => { '', '', 'Rules:', + '- If the task requires reading, writing, editing, searching, shell execution, browser use, or any action covered by an available tool, your visible response MUST be a `` block. Call the tool instead of describing the action.', + '- A tool call must be the first non-whitespace content of the visible answer. Do not write “I will…”, “Let me…”, “我将…”, “正在…”, a plan, or a completion claim before it.', '- The JSON inside `` must be valid and on a single logical block.', '- Use the exact tool name listed above.', '- Provide all required arguments; omit unknown ones.', '- You may emit multiple `` blocks back-to-back when more than one tool is needed.', - '- After tool results are returned (as user/tool messages), continue the reply normally.', - '- Do not wrap `` blocks in code fences or extra commentary.' + '- After every tool result, evaluate the actual task state. If work remains, emit the next tool call. Only return a normal-language final answer after the requested task is genuinely complete or you are blocked on user input.', + '- Never claim that a file was changed, a command succeeded, or a result was verified unless the corresponding tool result proves it.', + '- Do not call nonexistent tools, fabricate tool results, wrap `` in code fences, or mix extra commentary into a tool-call turn.' ]; const choice = options.tool_choice; @@ -200,8 +221,8 @@ const foldToolMessages = (messages) => { const callId = message.tool_call_id || ''; const name = message.name || callIdToName.get(callId) || 'tool'; const content = typeof message.content === 'string' - ? message.content - : JSON.stringify(message.content ?? ''); + ? (message.content || 'null') + : JSON.stringify(message.content ?? null); const idAttr = callId ? ` tool_call_id="${escapeAttr(callId)}"` : ''; return { role: 'user', @@ -504,5 +525,6 @@ module.exports = { parseToolCallsFromText, createToolCallStreamParser, createNativeToolCallAccumulator, + looksLikeUnexecutedToolAction, serializeToolArguments }; diff --git a/src/utils/upload.js b/src/utils/upload.js index 9a1467a..068f016 100644 --- a/src/utils/upload.js +++ b/src/utils/upload.js @@ -59,6 +59,19 @@ const getSimpleFileType = (mimeType) => { */ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)) +const createAuthorizedHeaders = (authToken) => ({ + 'Authorization': authToken.startsWith('Bearer ') ? authToken : `Bearer ${authToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +}) + +const unwrapApiData = (response) => { + const payload = response?.data + return payload && payload.data && typeof payload.data === 'object' + ? payload.data + : payload +} + /** * 请求STS Token(带重试机制) * @param {string} filename - 文件名 @@ -115,7 +128,11 @@ const requestStsToken = async (filename, filesize, filetypeSimple, authToken, re const response = await axios.post(UPLOAD_CONFIG.stsTokenUrl, payload, requestConfig) if (response.status === 200 && response.data) { - const stsData = response.data + if (response.data.success === false) { + throw new Error(response.data?.data?.message || response.data?.message || 'STS Token 请求被上游拒绝') + } + // 同时兼容旧版直接字段与 FE 0.2.81 的 {success,data} 包装。 + const stsData = unwrapApiData(response) // 验证响应数据完整性 const credentials = { @@ -290,8 +307,111 @@ const uploadFileToQwenOss = async (fileBuffer, originalFilename, authToken, acco } } +/** + * 通知 Qwen 网页端解析已上传的文本文档,并等待解析完成。 + * Agent 长上下文必须通过这个步骤才能作为真正的文档上下文被模型读取。 + * @param {string} fileId + * @param {string} authToken + * @param {Object} [account] + * @param {Object} [options] + */ +const parseUploadedTextFile = async (fileId, authToken, account, options = {}) => { + if (!fileId || !authToken) throw new Error('解析文档缺少 fileId 或认证 Token') + + const baseUrl = getChatBaseUrl() + const requestConfig = applyProxyToAxiosConfig({ + headers: createAuthorizedHeaders(authToken), + timeout: Math.max(1000, Number(options.timeoutMs) || 30000) + }, account) + + await axios.post(`${baseUrl}/api/v2/files/parse`, { file_id: fileId }, requestConfig) + + const maxAttempts = Math.max(1, Number(options.maxAttempts) || 30) + const intervalMs = Math.max(50, Number(options.intervalMs) || 500) + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const response = await axios.post( + `${baseUrl}/api/v2/files/parse/status`, + { file_id_list: [fileId] }, + requestConfig + ) + const payload = unwrapApiData(response) + const records = Array.isArray(payload) ? payload : (payload?.list || payload?.items || []) + const record = records.find(item => item?.file_id === fileId) || records[0] + const status = String(record?.status || payload?.status || '').toLowerCase() + + if (status === 'success' || status === 'completed' || status === 'done') return true + if (status === 'failed' || status === 'error') { + throw new Error(record?.error_msg || record?.message || 'Qwen 文档解析失败') + } + if (attempt < maxAttempts) await delay(intervalMs) + } + + throw new Error(`Qwen 文档解析超时: ${fileId}`) +} + +/** + * 构造 Qwen Web 0.2.81 使用的 message.files 文档描述符。 + */ +const buildChatFileDescriptor = ({ fileId, fileUrl, filename, size }) => { + const timestamp = Date.now() + return { + type: 'file', + file: { + created_at: timestamp, + data: {}, + filename, + hash: null, + id: fileId, + meta: { + name: filename, + size, + content_type: 'text/plain', + parse_meta: { parse_status: 'success' } + }, + update_at: timestamp, + name: filename, + size, + type: 'text/plain' + }, + id: fileId, + url: fileUrl, + name: filename, + collection_name: '', + status: 'uploaded', + progress: 100, + greenNet: 'success', + size, + error: '', + itemId: fileId, + file_type: 'text/plain', + showType: 'file', + file_class: 'document', + context: 'full' + } +} + +/** + * 上传并解析 Agent 长上下文,返回可直接放入 message.files 的描述符。 + */ +const uploadAgentContextFile = async (text, authToken, account, options = {}) => { + const content = Buffer.from(String(text || ''), 'utf8') + if (content.length === 0) throw new Error('Agent 上下文为空') + const filename = options.filename || `QWEN2API_AGENT_CONTEXT_${Date.now()}.txt` + const uploaded = await uploadFileToQwenOss(content, filename, authToken, account) + await parseUploadedTextFile(uploaded.file_id, authToken, account, options) + return buildChatFileDescriptor({ + fileId: uploaded.file_id, + fileUrl: uploaded.file_url, + filename, + size: content.length + }) +} + module.exports = { - uploadFileToQwenOss + uploadFileToQwenOss, + parseUploadedTextFile, + buildChatFileDescriptor, + uploadAgentContextFile } diff --git a/src/utils/upstream-error.js b/src/utils/upstream-error.js new file mode 100644 index 0000000..60b2134 --- /dev/null +++ b/src/utils/upstream-error.js @@ -0,0 +1,56 @@ +class UpstreamResponseError extends Error { + constructor(message, code = 'upstream_error', details = null) { + super(message); + this.name = 'UpstreamResponseError'; + this.code = code; + this.publicMessage = message; + this.details = details; + } +} + +/** + * Qwen Web 有时以 HTTP 200 + 普通 JSON 返回 WAF/captcha 或业务失败。 + * 这些帧没有 choices,若直接跳过就会被误包装成空成功或正常 stop。 + */ +const assertNoUpstreamFailure = (payload) => { + 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 => /FAIL_SYS_USER_VALIDATE|RGV587|captcha|\/punish\?/i.test(item))) { + throw new UpstreamResponseError( + 'Qwen 网页上游触发 WAF/captcha;Agent 上下文可能过大或账号需要验证', + 'upstream_waf_challenge', + { ret } + ); + } + + const explicitError = payload.error; + if (explicitError && !Array.isArray(payload.choices)) { + const message = typeof explicitError === 'string' + ? explicitError + : (explicitError.message || explicitError.msg || 'Qwen 上游返回业务错误'); + throw new UpstreamResponseError(message, explicitError.code || 'upstream_business_error'); + } + + if (payload.success === false && !Array.isArray(payload.choices)) { + const message = payload.data?.details || payload.data?.message || payload.message || 'Qwen 上游返回业务错误'; + throw new UpstreamResponseError( + message, + payload.data?.code || payload.code || 'upstream_business_error' + ); + } +}; + +module.exports = { + UpstreamResponseError, + assertNoUpstreamFailure +}; diff --git a/tests/agent-protocol.test.js b/tests/agent-protocol.test.js index f0fcc14..e5569d3 100644 --- a/tests/agent-protocol.test.js +++ b/tests/agent-protocol.test.js @@ -15,6 +15,8 @@ const { handleAnthropicStream, handleAnthropicNonStream } = require('../src/controllers/anthropic.js') +const { externalizeOversizedAgentContext } = require('../src/utils/request.js') +const { assertNoUpstreamFailure } = require('../src/utils/upstream-error.js') test.after(() => { require('../src/utils/account.js').destroy() @@ -100,7 +102,7 @@ test('controller modules can consume fragmented terminal frames', async () => { assert.equal(result.sawDone, true) }) -test('OpenAI stream propagates length and rejects incomplete EOF', async () => { +test('OpenAI stream preserves length, accepts clean EOF and rejects transport aborts', async () => { const completedRes = createMockResponse() await handleStreamResponse( completedRes, @@ -116,17 +118,272 @@ test('OpenAI stream propagates length and rejects incomplete EOF', async () => { assert.match(completedRes.output, /"finish_reason":"length"/) assert.doesNotMatch(completedRes.output, /"finish_reason":"stop"/) - const incompleteRes = createMockResponse() + const cleanEofRes = createMockResponse() await handleStreamResponse( - incompleteRes, - Readable.from(['data: {"choices":[{"delta":{"content":"cut"},"finish_reason":null}]}\n\n']), + cleanEofRes, + Readable.from(['data: {"choices":[{"delta":{"content":"normal eof"},"finish_reason":null}]}\n\n']), false, false, { messages: [] }, {} ) - assert.match(incompleteRes.output, /"code":"upstream_incomplete"/) - assert.doesNotMatch(incompleteRes.output, /"finish_reason":"stop"/) + assert.match(cleanEofRes.output, /"finish_reason":"stop"/) + assert.doesNotMatch(cleanEofRes.output, /upstream_incomplete/) + + async function * brokenStream() { + yield 'data: {"choices":[{"delta":{"content":"cut"},"finish_reason":null}]}\n\n' + const error = new Error('socket reset') + error.code = 'ECONNRESET' + throw error + } + const abortedRes = createMockResponse() + await handleStreamResponse( + abortedRes, + Readable.from(brokenStream()), + false, + false, + { messages: [] }, + {} + ) + assert.match(abortedRes.output, /"code":"upstream_stream_error"/) + assert.doesNotMatch(abortedRes.output, /"finish_reason":"stop"/) +}) + +test('thinking-only Agent turns retry once and recover visible output', async () => { + let openAIRetries = 0 + const openAIRes = createMockResponse() + await handleStreamResponse( + openAIRes, + Readable.from(['data: {"choices":[{"delta":{"phase":"think","content":"planning"},"finish_reason":null}]}\n\n']), + true, + false, + { messages: [{ role: 'user', content: 'finish the task' }] }, + { + sendChatRequest: async () => { + openAIRetries += 1 + return { + status: true, + response: Readable.from(['data: {"choices":[{"delta":{"phase":"answer","content":"recovered"},"finish_reason":null}]}\n\n']) + } + } + } + ) + assert.equal(openAIRetries, 1) + assert.match(openAIRes.output, /recovered/) + assert.match(openAIRes.output, /"finish_reason":"stop"/) + + let anthropicRetries = 0 + const anthropicRes = createMockResponse() + await handleAnthropicStream( + anthropicRes, + { + message_id: 'msg_retry', + model: 'qwen-test', + hasTools: false, + requestBody: { messages: [{ role: 'user', content: 'finish the task' }] }, + sendRequest: async () => { + anthropicRetries += 1 + return { + status: true, + response: Readable.from(['data: {"choices":[{"delta":{"phase":"answer","content":"recovered"},"finish_reason":null}]}\n\n']) + } + } + }, + Readable.from(['data: {"choices":[{"delta":{"phase":"think","content":"planning"},"finish_reason":null}]}\n\n']) + ) + assert.equal(anthropicRetries, 1) + assert.match(anthropicRes.output, /recovered/) + assert.match(anthropicRes.output, /event: message_stop/) +}) + +test('prose-only Agent actions are retried into executable tool calls', async () => { + let openAIRetries = 0 + const openAIRes = createMockResponse() + await handleStreamResponse( + openAIRes, + Readable.from(['data: {"choices":[{"delta":{"phase":"answer","content":"I will inspect the repository now."},"finish_reason":null}]}\n\n']), + false, + false, + { messages: [{ role: 'user', content: 'fix the project' }] }, + { + has_tools: true, + tool_choice: 'auto', + allowed_tool_names: ['read_file'], + sendChatRequest: async () => { + openAIRetries += 1 + return { + status: true, + response: Readable.from([ + 'data: {"choices":[{"delta":{"phase":"answer","content":"{\\"name\\":\\"read_file\\",\\"arguments\\":{\\"path\\":\\"README.md\\"}}"},"finish_reason":null}]}\n\n' + ]) + } + } + } + ) + assert.equal(openAIRetries, 1) + assert.match(openAIRes.output, /"name":"read_file"/) + assert.match(openAIRes.output, /"finish_reason":"tool_calls"/) + + let anthropicRetries = 0 + const anthropicRes = createMockResponse() + await handleAnthropicStream( + anthropicRes, + { + message_id: 'msg_action_retry', + model: 'qwen-test', + hasTools: true, + toolChoice: 'auto', + allowedToolNames: ['read_file'], + requestBody: { messages: [{ role: 'user', content: 'fix the project' }] }, + sendRequest: async () => { + anthropicRetries += 1 + return { + status: true, + response: Readable.from([ + 'data: {"choices":[{"delta":{"phase":"answer","content":"{\\"name\\":\\"read_file\\",\\"arguments\\":{\\"path\\":\\"README.md\\"}}"},"finish_reason":null}]}\n\n' + ]) + } + } + }, + Readable.from(['data: {"choices":[{"delta":{"phase":"answer","content":"我将读取项目文件。"},"finish_reason":null}]}\n\n']) + ) + assert.equal(anthropicRetries, 1) + assert.match(anthropicRes.output, /"type":"tool_use"/) + assert.match(anthropicRes.output, /"stop_reason":"tool_use"/) +}) + +test('clean-EOF tool turns keep Agent loops alive for OpenAI and Anthropic clients', async () => { + const toolFrame = 'data: {"choices":[{"delta":{"phase":"answer","content":"{\\"name\\":\\"read_file\\",\\"arguments\\":{\\"path\\":\\"README.md\\"}}"},"finish_reason":null}]}\n\n' + + const openAIRes = createMockResponse() + await handleStreamResponse( + openAIRes, + Readable.from([toolFrame]), + false, + false, + { messages: [] }, + { has_tools: true, tool_choice: 'auto', allowed_tool_names: ['read_file'] } + ) + assert.match(openAIRes.output, /"name":"read_file"/) + assert.match(openAIRes.output, /"finish_reason":"tool_calls"/) + assert.doesNotMatch(openAIRes.output, /"error"/) + + const anthropicRes = createMockResponse() + await handleAnthropicStream( + anthropicRes, + { + message_id: 'msg_tool_loop', + model: 'qwen-test', + hasTools: true, + toolChoice: 'auto', + allowedToolNames: ['read_file'], + requestBody: { messages: [] } + }, + Readable.from([toolFrame]) + ) + assert.match(anthropicRes.output, /"type":"tool_use"/) + assert.match(anthropicRes.output, /"stop_reason":"tool_use"/) + assert.match(anthropicRes.output, /event: message_stop/) +}) + +test('oversized Agent history is externalized while current turn stays live', async () => { + const original = [ + '# Tools', + 'strict tool protocol', + '# Conversation history (JSONL)', + JSON.stringify({ role: 'tool', content: 'x'.repeat(12000) }), + '# Current message', + JSON.stringify({ role: 'user', content: 'continue fixing the project' }) + ].join('\n') + let uploaded = '' + const result = await externalizeOversizedAgentContext( + { messages: [{ role: 'user', content: original, files: [] }], model: 'qwen-test' }, + 'token', + { email: 'test@example.com' }, + { + thresholdBytes: 1024, + livePromptBytes: 4096, + uploader: async text => { + uploaded = text + return { id: 'file_context', type: 'file', name: 'QWEN2API_AGENT_CONTEXT.txt' } + } + } + ) + + assert.equal(result.externalized, true) + assert.equal(uploaded, original) + assert.equal(result.payload.messages[0].files[0].id, 'file_context') + assert.match(result.payload.messages[0].content, /continue fixing the project/) + assert.match(result.payload.messages[0].content, /Agent context attachment/) + assert.ok(Buffer.byteLength(result.payload.messages[0].content) < Buffer.byteLength(original)) +}) + +test('oversized multimodal Agent context is externalized and upload failure keeps tool schemas', async () => { + const original = [ + '# Tools', + 'strict tool protocol with read_file(path: string)', + '# Conversation history (JSONL)', + JSON.stringify({ role: 'tool', content: 'x'.repeat(12000) }), + '# Current message', + JSON.stringify({ role: 'user', content: 'continue the unfinished task' }) + ].join('\n') + const media = { type: 'image_url', image_url: { url: 'https://example.test/screenshot.png' } } + const externalized = await externalizeOversizedAgentContext( + { messages: [{ role: 'user', content: [{ type: 'text', text: original }, media] }] }, + 'token', + {}, + { + thresholdBytes: 1024, + livePromptBytes: 4096, + uploader: async () => ({ id: 'file_context', name: 'QWEN2API_AGENT_CONTEXT_123.txt' }) + } + ) + assert.equal(externalized.externalized, true) + assert.match(externalized.payload.messages[0].content[0].text, /QWEN2API_AGENT_CONTEXT_123\.txt/) + assert.deepEqual(externalized.payload.messages[0].content[1], media) + + const compacted = await externalizeOversizedAgentContext( + { messages: [{ role: 'user', content: original }] }, + 'token', + {}, + { + thresholdBytes: 1024, + livePromptBytes: 4096, + uploader: async () => { throw new Error('parse failed') } + } + ) + assert.equal(compacted.compacted, true) + assert.match(compacted.payload.messages[0].content, /strict tool protocol with read_file/) + assert.match(compacted.payload.messages[0].content, /continue the unfinished task/) + assert.ok(Buffer.byteLength(compacted.payload.messages[0].content) <= 4096) +}) + +test('Qwen HTTP-200 WAF payload is surfaced as an explicit failure', () => { + assert.throws( + () => assertNoUpstreamFailure({ + ret: ['FAIL_SYS_USER_VALIDATE', 'RGV587_ERROR'], + data: { url: 'https://chat.qwen.ai/punish?action=captcha' } + }), + error => error.code === 'upstream_waf_challenge' + ) +}) + +test('Qwen HTTP-200 bare JSON WAF response reaches OpenAI clients explicitly', async () => { + const res = createMockResponse() + await handleStreamResponse( + res, + Readable.from([JSON.stringify({ + ret: ['FAIL_SYS_USER_VALIDATE', 'RGV587_ERROR'], + data: { url: 'https://chat.qwen.ai/punish?action=captcha' } + })]), + false, + false, + { messages: [] }, + {} + ) + assert.equal(res.statusCode, 502) + assert.match(res.output, /upstream_waf_challenge/) + assert.match(res.output, /WAF\\u002fcaptcha|WAF\/captcha/) }) test('Anthropic stream emits thinking signature, max_tokens and tool parse errors', async () => { diff --git a/tests/sse.test.js b/tests/sse.test.js index fabadb1..db51aca 100644 --- a/tests/sse.test.js +++ b/tests/sse.test.js @@ -36,6 +36,15 @@ test('SSEDecoder dispatches the final event even without a trailing blank line', assert.equal(frames[0].data, '{"ok":true}') }) +test('SSEDecoder surfaces HTTP-200 bare JSON business responses', () => { + const decoder = new SSEDecoder() + const payload = '{"ret":["FAIL_SYS_USER_VALIDATE"],"success":false}' + assert.deepEqual(decoder.push(payload), []) + const frames = decoder.end() + assert.equal(frames.length, 1) + assert.equal(frames[0].data, payload) +}) + test('consumeSSEStream serializes async handlers before resolving', async () => { const stream = new PassThrough() const seen = [] @@ -50,6 +59,7 @@ test('consumeSSEStream serializes async handlers before resolving', async () => assert.deepEqual(seen, ['one', 'two', '[DONE]']) assert.equal(result.sawDone, true) assert.equal(result.eventCount, 3) + assert.equal(result.completed, true) }) test('formatSSEFrame produces a frame that survives byte-by-byte decoding', async () => { diff --git a/tests/tool-prompt.test.js b/tests/tool-prompt.test.js index ffdfd9d..240f6d1 100644 --- a/tests/tool-prompt.test.js +++ b/tests/tool-prompt.test.js @@ -2,11 +2,43 @@ const test = require('node:test') const assert = require('node:assert/strict') const { + buildToolSystemPrompt, + foldToolMessages, + looksLikeUnexecutedToolAction, parseToolCallsFromText, createToolCallStreamParser, createNativeToolCallAccumulator } = require('../src/utils/tool-prompt.js') +test('Agent tool prompt forbids prose-only actions and premature completion', () => { + const prompt = buildToolSystemPrompt([{ + type: 'function', + function: { + name: 'write_file', + description: 'Write a file', + parameters: { + type: 'object', + properties: { path: { type: 'string', description: 'Target path' } }, + required: ['path'] + } + } + }]) + assert.match(prompt, /MUST be a `` block/) + assert.match(prompt, /Only return a normal-language final answer after the requested task is genuinely complete/) + assert.match(prompt, /path: string \/\* Target path \*\//) + assert.equal(looksLikeUnexecutedToolAction('I will inspect the repository now.'), true) + assert.equal(looksLikeUnexecutedToolAction('我将运行测试并检查结果。'), true) + assert.equal(looksLikeUnexecutedToolAction('这里是无需调用工具的概念解释。'), false) +}) + +test('empty tool results remain visible in Agent history', () => { + const folded = foldToolMessages([ + { role: 'assistant', content: '', tool_calls: [{ id: 'call_1', function: { name: 'read_file', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'call_1', content: '' } + ]) + assert.match(folded[1].content, />\nnull\n<\/tool_response>/) +}) + test('stream parser accepts split valid calls and preserves JSON string arguments', () => { const parser = createToolCallStreamParser({ allowedToolNames: ['read_file'] }) const first = parser.push('before