diff --git a/CHANGELOG.md b/CHANGELOG.md
index d32139b..22bcdfb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,26 @@
# Changelog
+## v2.7.4 (2026-03-18)
+
+### 🛡️ 截断安全 — 防止损坏的工具调用
+
+- **截断时跳过工具解析**:当响应被截断(`stop_reason=max_tokens`)时,不再尝试解析不完整的 `json action` 块,避免生成损坏的工具调用(如写入半截文件)
+- **纯文本回退**:截断响应中的不完整工具块被自动剥离,剩余文本作为纯文本返回,由客户端(Claude Code)原生续写
+- **默认禁用代理续写**:`maxAutoContinue` 默认值改为 `0`,让 Claude Code 原生处理续写(体验更好、进度可见),配置同步更新至 `config.yaml`、`config.yaml.example`、`docker-compose.yml`
+
+### 🧹 提示词注入防御增强
+
+- **身份声明清除**:自动剥离系统提示词中的 Claude Code / Anthropic 身份声明(`You are Claude Code`、`I'm Claude, made by Anthropic` 等),防止模型将其判定为 prompt injection 并拒绝服务
+- **流式热身窗口扩大**:混合流式模式的 `warmupChars` 从 96 增至 300 字符,确保拒绝检测完成前不释放任何文本给客户端
+
+### 📊 日志查看器增强
+
+- **提示词对比视图**:「💬 提示词」tab 重命名为「💬 提示词对比」,分区展示原始请求 vs 转换后的 Cursor 消息
+- **转换摘要面板**:顶部新增 6 格摘要(原始工具数 → Cursor 工具数 0、工具指令占用字符数、消息数变化、总上下文大小)
+- **工具去向提示**:当有工具时显示黄色提示「Cursor API 不支持原生 tools 参数,N 个工具已转换为文本指令嵌入 user #1」
+- **标题提取优化**:通用 XML 标签清除(覆盖所有注入标签)+ 清除 `Respond with the appropriate action` 引导语
+
+---
## v2.7.2 (2026-03-17)
### 🖥️ 日志查看器全面升级
diff --git a/README.md b/README.md
index f9249b9..d5fbb69 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# Cursor2API v2.7.3
+# Cursor2API v2.7.4
将 Cursor 文档页免费 AI 对话接口代理转换为 **Anthropic Messages API** 和 **OpenAI Chat Completions API**,支持 **Claude Code** 和 **Cursor IDE** 使用。
-> ⚠️ **版本说明**:当前 v2.7.3 统一 thinking 剥离逻辑、增强拒绝检测准确性、优化 Docker 部署配置。
+> ⚠️ **版本说明**:当前 v2.7.4 截断安全(防止损坏工具调用)、默认禁用代理续写(让客户端原生续写)、日志查看器提示词对比视图。
## 原理
@@ -80,6 +80,7 @@ cp config.yaml.example config.yaml
| `logging.file_enabled` | 日志文件持久化 | `false` |
| `logging.dir` | 日志存储目录 | `./logs` |
| `logging.max_days` | 日志保留天数 | `7` |
+| `max_auto_continue` | 截断自动续写次数 (`0`=禁用,交由客户端续写) | `0` |
> 💡 详细配置说明请参见 `config.yaml.example` 中的注释。
@@ -241,6 +242,7 @@ AI 按此格式输出 → 我们解析并转换为标准的 Anthropic `tool_use`
| `COMPRESSION_LEVEL` | 压缩级别 (`1`/`2`/`3`) |
| `LOG_FILE_ENABLED` | 日志文件持久化 (`true`/`false`) |
| `LOG_DIR` | 日志文件目录 |
+| `MAX_AUTO_CONTINUE` | 截断自动续写次数 (`0`=禁用) |
## 免责声明 / Disclaimer
diff --git a/config.yaml.example b/config.yaml.example
index 55eb7d0..70bb6ef 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -29,16 +29,17 @@ cursor_model: "anthropic/claude-sonnet-4.6"
# ==================== 自动续写配置 ====================
# 当模型输出被截断时,自动发起续写请求的最大次数
-# 设为 0 可完全禁用自动续写(由用户在对话中手动续写)
-# 环境变量: MAX_AUTO_CONTINUE=3
-max_auto_continue: 3
+# 默认 0(禁用),由客户端(如 Claude Code)自行处理续写,体验更好
+# 设为 1~3 可启用 proxy 内部续写(拼接更完整,但延迟更高)
+# 环境变量: MAX_AUTO_CONTINUE=0
+max_auto_continue: 0
# ==================== 历史消息条数硬限制 ====================
# 输入消息条数上限,超出时删除最早的消息(保留工具 few-shot 示例)
# 防止超长对话(800+ 条)导致请求体积过大、响应变慢
# 设为 -1 不限制消息条数
# 环境变量: MAX_HISTORY_MESSAGES=100
-max_history_messages: 100
+max_history_messages: -1
# ==================== Thinking 开关(最高优先级) ====================
# 控制是否向 Cursor 发送 thinking 请求,优先级高于客户端传入的 thinking 参数
@@ -46,8 +47,8 @@ max_history_messages: 100
# 设为 false: 强制关闭 thinking(即使客户端请求了 thinking 也不启用)
# 不配置此项时: 跟随客户端请求(Anthropic API 看 thinking 参数,OpenAI API 看模型名/reasoning_effort)
# 环境变量: THINKING_ENABLED=true|false
-# thinking:
-# enabled: false
+thinking:
+ enabled: false
# ==================== 历史消息压缩配置 ====================
# 对话过长时自动压缩早期消息,释放输出空间,防止 Cursor 上下文溢出
@@ -55,40 +56,40 @@ max_history_messages: 100
compression:
# 是否启用压缩(true/false),关闭后所有消息原样保留
# 环境变量: COMPRESSION_ENABLED=true|false
- enabled: true
+ enabled: false
- # 压缩级别: 1=轻度, 2=中等(默认), 3=激进
+ # 压缩级别: 1=轻度(默认), 2=中等, 3=激进
# 环境变量: COMPRESSION_LEVEL=1|2|3
# 级别说明:
- # 1(轻度): 保留最近 10 条消息,早期消息保留 4000 字符,适合短对话
- # 2(中等): 保留最近 6 条消息,早期消息保留 2000 字符,推荐日常使用
+ # 1(轻度): 保留最近 10 条消息,早期消息保留 4000 字符,适合日常使用(默认)
+ # 2(中等): 保留最近 6 条消息,早期消息保留 2000 字符,适合中长对话
# 3(激进): 保留最近 4 条消息,早期消息保留 1000 字符,适合超长对话/大工具集
- level: 2
+ level: 1
# 以下为高级选项,设置后会覆盖 level 的预设值
# 保留最近 N 条消息不压缩(数字越大保留越多上下文)
- # keep_recent: 6
+ # keep_recent: 10
# 早期消息最大字符数(超过此长度的消息会被智能压缩)
- # early_msg_max_chars: 2000
+ # early_msg_max_chars: 4000
# ==================== 工具处理配置 ====================
# 控制工具定义如何传递给模型,影响上下文体积和工具调用准确性
tools:
# Schema 呈现模式
- # 'compact': [默认推荐] TypeScript 风格的紧凑签名,体积最小(~15K chars/90工具)
+ # 'compact': TypeScript 风格的紧凑签名,体积最小(~15K chars/90工具)
# 示例: {file_path!: string, encoding?: utf-8|base64}
- # 'full': 完整 JSON Schema,体积最大(~135K chars/90工具),工具调用最精确
- # 适合工具少(<20个)或参数复杂的场景
+ # 'full': [默认] 完整 JSON Schema,工具调用最精确
+ # 适合工具少(<20个)或需要最高准确率的场景
# 'names_only': 只输出工具名和描述,不输出参数Schema
# 极致省 token,适合模型已经"学过"这些工具的场景(如 Claude Code 内置工具)
- schema_mode: 'compact'
+ schema_mode: 'full'
# 工具描述截断长度
- # 50: [默认推荐] 截断到 50 个字符,节省上下文
- # 0: 不截断,保留完整描述(适合工具少的场景)
+ # 0: [默认] 不截断,保留完整描述,工具理解最准确
+ # 50: 截断到 50 个字符,节省上下文(适合工具多的场景)
# 200: 中等截断,保留大部分有用信息
- description_max_length: 50
+ description_max_length: 0
# 工具白名单 — 只保留指定名称的工具(不配则保留所有工具)
# 💡 适合只用核心工具、排除大量不需要的 MCP 工具等场景
diff --git a/docker-compose.yml b/docker-compose.yml
index ccedc01..0aeec92 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,12 +33,12 @@ services:
# - THINKING_ENABLED=true
# ── 历史消息压缩 ──
- # - COMPRESSION_ENABLED=true
- # - COMPRESSION_LEVEL=2
+ # - COMPRESSION_ENABLED=false
+ # - COMPRESSION_LEVEL=1
# ── 自动续写 & 历史消息限制 ──
- # - MAX_AUTO_CONTINUE=3 # 截断后自动续写次数,0=禁用
- # - MAX_HISTORY_MESSAGES=100 # 历史消息条数上限,-1=不限制
+ # - MAX_AUTO_CONTINUE=0 # 截断后自动续写次数,0=禁用(默认)
+ # - MAX_HISTORY_MESSAGES=-1 # 历史消息条数上限,-1=不限制
# ── 日志持久化 ──
# - LOG_FILE_ENABLED=true
diff --git a/package.json b/package.json
index ba3f77b..0eed37b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cursor2api",
- "version": "2.7.3",
+ "version": "2.7.4",
"description": "Proxy Cursor docs AI to Anthropic Messages API for Claude Code",
"type": "module",
"scripts": {
diff --git a/public/logs.html b/public/logs.html
index dc707a2..dee09b5 100644
--- a/public/logs.html
+++ b/public/logs.html
@@ -64,7 +64,7 @@
diff --git a/public/logs.js b/public/logs.js
index 465f54d..3b5c62d 100644
--- a/public/logs.js
+++ b/public/logs.js
@@ -234,12 +234,40 @@ function renderRequestTab(tc){
function renderPromptsTab(tc){
if(!curPayload){tc.innerHTML='
';return}
let h='';
+ const s=selId?rmap[selId]:null;
+ // ===== 转换摘要 =====
+ if(s){
+ const origMsgCount=curPayload.messages?curPayload.messages.length:0;
+ const cursorMsgCount=curPayload.cursorMessages?curPayload.cursorMessages.length:0;
+ const origToolCount=s.toolCount||0;
+ const sysPLen=curPayload.systemPrompt?curPayload.systemPrompt.length:0;
+ const cursorTotalChars=curPayload.cursorRequest?.totalChars||0;
+ // 计算工具指令占用的字符数(第一条 cursor 消息 减去 原始第一条用户消息)
+ const firstCursorMsg=curPayload.cursorMessages?.[0];
+ const firstOrigUser=curPayload.messages?.find(m=>m.role==='user');
+ const toolInstructionChars=firstCursorMsg&&firstOrigUser?Math.max(0,firstCursorMsg.contentLength-(firstOrigUser?.contentLength||0)):0;
+ h+='
🔄 转换摘要
';
+ h+='
';
+ h+='
原始工具数'+origToolCount+'
';
+ h+='
Cursor 工具数0 (嵌入消息)
';
+ h+='
工具指令占用'+(toolInstructionChars>0?fmtN(toolInstructionChars)+' chars':origToolCount>0?'嵌入第1条消息':'N/A')+'
';
+ h+='
原始消息数'+origMsgCount+'
';
+ h+='
Cursor 消息数'+cursorMsgCount+'
';
+ h+='
总上下文大小'+(cursorTotalChars>0?fmtN(cursorTotalChars)+' chars':'—')+'
';
+ h+='
';
+ if(origToolCount>0){
+ h+='
⚠️ Cursor API 不支持原生 tools 参数。'+origToolCount+' 个工具定义已转换为文本指令,嵌入在 user #1 消息中'+(toolInstructionChars>0?'(约 '+fmtN(toolInstructionChars)+' chars)':'')+'
';
+ }
+ h+='
';
+ }
+ // ===== 原始请求 =====
+ h+='
';
if(curPayload.systemPrompt){
- h+='
🔒 System Prompt '+fmtN(curPayload.systemPrompt.length)+' chars
';
- h+='
'+escH(curPayload.systemPrompt)+'
';
+ h+='
🔒 原始 System Prompt '+fmtN(curPayload.systemPrompt.length)+' chars
';
+ h+='
'+escH(curPayload.systemPrompt)+'
';
}
if(curPayload.messages&&curPayload.messages.length){
- h+='
💬 消息列表 '+curPayload.messages.length+' 条
';
+ h+='
💬 原始消息列表 '+curPayload.messages.length+' 条
';
curPayload.messages.forEach((m,i)=>{
const imgs=m.hasImages?' 🖼️':'';
const collapsed=m.contentPreview.length>500;
@@ -247,6 +275,19 @@ function renderPromptsTab(tc){
});
h+='
';
}
+ // ===== 转换后 Cursor 请求 =====
+ if(curPayload.cursorMessages&&curPayload.cursorMessages.length){
+ h+='
📤 Cursor 最终消息(转换后) '+curPayload.cursorMessages.length+' 条
';
+ h+='
⬇️ 以下是清洗后实际发给 Cursor 模型的消息(已清除身份声明、注入工具指令、添加认知重构)
';
+ curPayload.cursorMessages.forEach((m,i)=>{
+ const collapsed=m.contentPreview.length>500;
+ h+='
'+escH(m.contentPreview)+'
';
+ });
+ h+='
';
+ } else if(curPayload.cursorRequest) {
+ h+='
📤 Cursor 最终请求(转换后)
';
+ h+='
'+syntaxHL(curPayload.cursorRequest)+'
';
+ }
tc.innerHTML=h||'
';
}
diff --git a/src/config.ts b/src/config.ts
index e9866c7..e7899f6 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -12,8 +12,8 @@ export function getConfig(): AppConfig {
port: 3010,
timeout: 120,
cursorModel: 'anthropic/claude-sonnet-4.6',
- maxAutoContinue: 2,
- maxHistoryMessages: 100,
+ maxAutoContinue: 0,
+ maxHistoryMessages: -1,
fingerprint: {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
},
@@ -54,9 +54,9 @@ export function getConfig(): AppConfig {
const c = yaml.compression;
config.compression = {
enabled: c.enabled !== false, // 默认启用
- level: [1, 2, 3].includes(c.level) ? c.level : 2,
- keepRecent: typeof c.keep_recent === 'number' ? c.keep_recent : 6,
- earlyMsgMaxChars: typeof c.early_msg_max_chars === 'number' ? c.early_msg_max_chars : 2000,
+ level: [1, 2, 3].includes(c.level) ? c.level : 1,
+ keepRecent: typeof c.keep_recent === 'number' ? c.keep_recent : 10,
+ earlyMsgMaxChars: typeof c.early_msg_max_chars === 'number' ? c.early_msg_max_chars : 4000,
};
}
// ★ Thinking 开关(最高优先级)
@@ -78,8 +78,8 @@ export function getConfig(): AppConfig {
const t = yaml.tools;
const validModes = ['compact', 'full', 'names_only'];
config.tools = {
- schemaMode: validModes.includes(t.schema_mode) ? t.schema_mode : 'compact',
- descriptionMaxLength: typeof t.description_max_length === 'number' ? t.description_max_length : 50,
+ schemaMode: validModes.includes(t.schema_mode) ? t.schema_mode : 'full',
+ descriptionMaxLength: typeof t.description_max_length === 'number' ? t.description_max_length : 0,
includeOnly: Array.isArray(t.include_only) ? t.include_only.map(String) : undefined,
exclude: Array.isArray(t.exclude) ? t.exclude.map(String) : undefined,
};
@@ -101,11 +101,11 @@ export function getConfig(): AppConfig {
}
// 压缩环境变量覆盖
if (process.env.COMPRESSION_ENABLED !== undefined) {
- if (!config.compression) config.compression = { enabled: true, level: 2, keepRecent: 6, earlyMsgMaxChars: 2000 };
+ if (!config.compression) config.compression = { enabled: false, level: 1, keepRecent: 10, earlyMsgMaxChars: 4000 };
config.compression.enabled = process.env.COMPRESSION_ENABLED !== 'false' && process.env.COMPRESSION_ENABLED !== '0';
}
if (process.env.COMPRESSION_LEVEL) {
- if (!config.compression) config.compression = { enabled: true, level: 2, keepRecent: 6, earlyMsgMaxChars: 2000 };
+ if (!config.compression) config.compression = { enabled: false, level: 1, keepRecent: 10, earlyMsgMaxChars: 4000 };
const lvl = parseInt(process.env.COMPRESSION_LEVEL);
if (lvl >= 1 && lvl <= 3) config.compression.level = lvl as 1 | 2 | 3;
}
diff --git a/src/converter.ts b/src/converter.ts
index 8fe9dc8..6f9b0a1 100644
--- a/src/converter.ts
+++ b/src/converter.ts
@@ -219,6 +219,9 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise
0 && shouldAutoContinueTruncatedToolResponse(fullResponse, hasTools) && continueCount < MAX_AUTO_CONTINUE) {
continueCount++;
@@ -718,7 +718,9 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
const continuationReq: CursorChatRequest = {
...cursorReq,
messages: [
- ...originalMessages,
+ // ★ 续写优化:丢弃所有工具定义和历史消息,只保留续写上下文
+ // 模型已经知道在写什么(从 assistantContext 可以推断),不需要工具 Schema
+ // 这样大幅减少输入体积,给输出留更多空间,续写更快
{
parts: [{ type: 'text', text: assistantContext }],
id: uuidv4(),
@@ -767,7 +769,6 @@ export async function autoContinueCursorToolResponseFull(
const MAX_AUTO_CONTINUE = getConfig().maxAutoContinue;
let continueCount = 0;
let consecutiveSmallAdds = 0;
- const originalMessages = [...cursorReq.messages];
while (MAX_AUTO_CONTINUE > 0 && shouldAutoContinueTruncatedToolResponse(fullText, hasTools) && continueCount < MAX_AUTO_CONTINUE) {
continueCount++;
@@ -789,7 +790,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
const continuationReq: CursorChatRequest = {
...cursorReq,
messages: [
- ...originalMessages,
+ // ★ 续写优化:丢弃所有工具定义和历史消息
{
parts: [{ type: 'text', text: assistantContext }],
id: uuidv4(),
@@ -1171,7 +1172,7 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
let activeCursorReq = cursorReq;
let retryCount = 0;
- const executeStream = async (detectRefusalEarly = false): Promise<{ earlyAborted: boolean }> => {
+ const executeStream = async (detectRefusalEarly = false, onTextDelta?: (delta: string) => void): Promise<{ earlyAborted: boolean }> => {
fullResponse = '';
const apiStart = Date.now();
let firstChunk = true;
@@ -1186,6 +1187,7 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
if (event.type !== 'text-delta' || !event.delta) return;
if (firstChunk) { log.recordTTFT(); log.endPhase(); log.startPhase('response', '接收响应'); firstChunk = false; }
fullResponse += event.delta;
+ onTextDelta?.(event.delta);
// ★ 早期拒绝检测:前 300 字符即可判断
if (detectRefusalEarly && !earlyAborted && fullResponse.length >= 200 && fullResponse.length < 600) {
@@ -1217,7 +1219,8 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
return;
}
- // 工具模式:创建 keepalive(无工具路径已在 handleDirectTextStream 内部处理)
+ // ★ 工具模式:混合流式 — 文本增量推送 + 工具块缓冲
+ // 用户体验优化:工具调用前的文字立即逐字流式,不再等全部生成完毕
keepaliveInterval = setInterval(() => {
try {
res.write(': keepalive\n\n');
@@ -1226,7 +1229,127 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
} catch { /* connection already closed, ignore */ }
}, 15000);
- await executeStream(true); // ★ 启用早期拒绝检测,节省 2-5s/次
+ // --- 混合流式状态 ---
+ const hybridStreamer = createIncrementalTextStreamer({
+ warmupChars: 300, // ★ 与拒绝检测窗口对齐:前 300 chars 不释放,等拒绝检测通过后再流
+ transform: sanitizeResponse,
+ isBlockedPrefix: (text) => isRefusal(text.substring(0, 300)),
+ });
+ let toolMarkerDetected = false;
+ let pendingText = ''; // 边界检测缓冲区
+ let hybridThinkingContent = '';
+ let hybridLeadingBuffer = '';
+ let hybridLeadingResolved = false;
+ const TOOL_MARKER = '```json action';
+ const MARKER_LOOKBACK = TOOL_MARKER.length + 2; // +2 for newline safety
+ let hybridTextSent = false; // 是否已经向客户端发过文字
+
+ const hybridState = { blockIndex, textBlockStarted, thinkingEmitted: thinkingBlockEmitted };
+
+ const pushToStreamer = (text: string): void => {
+ if (!text || toolMarkerDetected) return;
+
+ pendingText += text;
+ const idx = pendingText.indexOf(TOOL_MARKER);
+ if (idx >= 0) {
+ // 工具标记出现 → flush 标记前的文字,切换到缓冲模式
+ const before = pendingText.substring(0, idx);
+ if (before) {
+ const d = hybridStreamer.push(before);
+ if (d) {
+ if (clientRequestedThinking && hybridThinkingContent && !hybridState.thinkingEmitted) {
+ emitAnthropicThinkingBlock(res, hybridState, hybridThinkingContent);
+ }
+ writeAnthropicTextDelta(res, hybridState, d);
+ hybridTextSent = true;
+ }
+ }
+ toolMarkerDetected = true;
+ pendingText = '';
+ return;
+ }
+
+ // 安全刷出:保留末尾 MARKER_LOOKBACK 长度防止标记被截断
+ const safeEnd = pendingText.length - MARKER_LOOKBACK;
+ if (safeEnd > 0) {
+ const safe = pendingText.substring(0, safeEnd);
+ pendingText = pendingText.substring(safeEnd);
+ const d = hybridStreamer.push(safe);
+ if (d) {
+ if (clientRequestedThinking && hybridThinkingContent && !hybridState.thinkingEmitted) {
+ emitAnthropicThinkingBlock(res, hybridState, hybridThinkingContent);
+ }
+ writeAnthropicTextDelta(res, hybridState, d);
+ hybridTextSent = true;
+ }
+ }
+ };
+
+ const processHybridDelta = (delta: string): void => {
+ // 前导 thinking 检测(与 handleDirectTextStream 完全一致)
+ if (!hybridLeadingResolved) {
+ hybridLeadingBuffer += delta;
+ const split = splitLeadingThinkingBlocks(hybridLeadingBuffer);
+ if (split.startedWithThinking) {
+ if (!split.complete) return;
+ hybridThinkingContent = split.thinkingContent;
+ hybridLeadingResolved = true;
+ hybridLeadingBuffer = '';
+ pushToStreamer(split.remainder);
+ return;
+ }
+ if (hybridLeadingBuffer.trimStart().length < THINKING_OPEN.length) return;
+ hybridLeadingResolved = true;
+ const buffered = hybridLeadingBuffer;
+ hybridLeadingBuffer = '';
+ pushToStreamer(buffered);
+ return;
+ }
+ pushToStreamer(delta);
+ };
+
+ // 执行第一次请求(带混合流式回调)
+ await executeStream(true, processHybridDelta);
+
+ // 流结束:flush 残留的 leading buffer
+ if (!hybridLeadingResolved && hybridLeadingBuffer) {
+ hybridLeadingResolved = true;
+ const split = splitLeadingThinkingBlocks(hybridLeadingBuffer);
+ if (split.startedWithThinking && split.complete) {
+ hybridThinkingContent = split.thinkingContent;
+ pushToStreamer(split.remainder);
+ } else {
+ pushToStreamer(hybridLeadingBuffer);
+ }
+ }
+ // flush 残留的 pendingText(没有检测到工具标记)
+ if (pendingText && !toolMarkerDetected) {
+ const d = hybridStreamer.push(pendingText);
+ if (d) {
+ if (clientRequestedThinking && hybridThinkingContent && !hybridState.thinkingEmitted) {
+ emitAnthropicThinkingBlock(res, hybridState, hybridThinkingContent);
+ }
+ writeAnthropicTextDelta(res, hybridState, d);
+ hybridTextSent = true;
+ }
+ pendingText = '';
+ }
+ // finalize streamer 残留文本
+ const hybridRemaining = hybridStreamer.finish();
+ if (hybridRemaining) {
+ if (clientRequestedThinking && hybridThinkingContent && !hybridState.thinkingEmitted) {
+ emitAnthropicThinkingBlock(res, hybridState, hybridThinkingContent);
+ }
+ writeAnthropicTextDelta(res, hybridState, hybridRemaining);
+ hybridTextSent = true;
+ }
+ // 同步混合流式状态回主变量
+ blockIndex = hybridState.blockIndex;
+ textBlockStarted = hybridState.textBlockStarted;
+ thinkingBlockEmitted = hybridState.thinkingEmitted;
+ // ★ 混合流式标记:记录已通过增量流发送给客户端的状态
+ // 后续 SSE 输出阶段根据此标记跳过已发送的文字
+ const hybridAlreadySentText = hybridTextSent;
log.recordRawResponse(fullResponse);
log.info('Handler', 'response', `原始响应: ${fullResponse.length} chars`, {
@@ -1235,12 +1358,12 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
});
// ★ Thinking 提取(在拒绝检测之前,防止 thinking 内容触发 isRefusal 误判)
- // 始终剥离 thinking 标签,避免泄漏到最终文本中
- let thinkingContent = '';
+ // 混合流式阶段可能已经提取了 thinking,优先使用
+ let thinkingContent = hybridThinkingContent || '';
if (fullResponse.includes('')) {
const { thinkingContent: extracted, strippedText } = extractThinking(fullResponse);
if (extracted) {
- thinkingContent = extracted;
+ if (!thinkingContent) thinkingContent = extracted;
fullResponse = strippedText;
log.recordThinking(thinkingContent);
log.updateSummary({ thinkingChars: thinkingContent.length });
@@ -1253,8 +1376,10 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
}
// 拒绝检测 + 自动重试
- // fullResponse 已在上方剥离 thinking 标签,可直接用于拒绝检测
+ // ★ 混合流式保护:如果已经向客户端发送了文字,不能重试(会导致内容重复)
+ // IncrementalTextStreamer 的 isBlockedPrefix 机制保证拒绝一定在发送任何文字之前被检测到
const shouldRetryRefusal = () => {
+ if (hybridTextSent) return false; // 已发文字,不可重试
if (!isRefusal(fullResponse)) return false;
if (hasTools && hasToolCalls(fullResponse)) return false;
return true;
@@ -1266,7 +1391,7 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
log.updateSummary({ retryCount });
const retryBody = buildRetryRequest(body, retryCount - 1);
activeCursorReq = await convertToCursorRequest(retryBody);
- await executeStream(true); // 重试也启用早期中止
+ await executeStream(true); // 重试不传回调(纯缓冲模式)
// 重试后也需要剥离 thinking 标签
if (fullResponse.includes('')) {
const { thinkingContent: retryThinking, strippedText: retryStripped } = extractThinking(fullResponse);
@@ -1309,12 +1434,10 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
// 流完成后,处理完整响应
// ★ 内部截断续写:如果模型输出过长被截断(常见于写大文件),Proxy 内部分段续写,然后拼接成完整响应
// 这样可以确保工具调用(如 Write)不会横跨两次 API 响应而退化为纯文本
- const MAX_AUTO_CONTINUE = getConfig().maxAutoContinue ?? 2; // Set default to 2
+ const MAX_AUTO_CONTINUE = getConfig().maxAutoContinue ?? 0;
let continueCount = 0;
let consecutiveSmallAdds = 0; // 连续小增量计数
-
- // 保存原始请求的消息快照(不含续写追加的消息)
- const originalMessages = [...activeCursorReq.messages];
+
while (MAX_AUTO_CONTINUE > 0 && shouldAutoContinueTruncatedToolResponse(fullResponse, hasTools) && continueCount < MAX_AUTO_CONTINUE) {
continueCount++;
@@ -1343,7 +1466,7 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
activeCursorReq = {
...activeCursorReq,
messages: [
- ...originalMessages,
+ // ★ 续写优化:丢弃所有工具定义和历史消息
{
parts: [{ type: 'text', text: assistantContext }],
id: uuidv4(),
@@ -1407,10 +1530,10 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
log.warn('Handler', 'truncation', `${MAX_AUTO_CONTINUE}次续写后仍截断 (${fullResponse.length} chars) → stop_reason=max_tokens`);
}
- // ★ Thinking 块发送:仅 GUI 插件(enabled)才发 thinking content block
- // Claude Code(adaptive)需要密码学 signature 验证,无法伪造,所以保留标签在正文中
+ // ★ Thinking 块发送:仅在混合流式未发送 thinking 时才在此发送
+ // 混合流式阶段已通过 emitAnthropicThinkingBlock 发送过的不重复发
log.startPhase('stream', 'SSE 输出');
- if (clientRequestedThinking && thinkingContent) {
+ if (clientRequestedThinking && thinkingContent && !thinkingBlockEmitted) {
writeSSE(res, 'content_block_start', {
type: 'content_block_start', index: blockIndex,
content_block: { type: 'thinking', thinking: '' },
@@ -1426,6 +1549,32 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
}
if (hasTools) {
+ // ★ 截断保护:如果响应被截断,不要解析不完整的工具调用
+ // 直接作为纯文本返回 max_tokens,让客户端自行处理续写
+ if (stopReason === 'max_tokens') {
+ log.info('Handler', 'truncation', '响应截断,跳过工具解析,作为纯文本返回 max_tokens');
+ // 去掉不完整的 ```json action 块
+ const incompleteToolIdx = fullResponse.lastIndexOf('```json action');
+ const textOnly = incompleteToolIdx >= 0 ? fullResponse.substring(0, incompleteToolIdx).trimEnd() : fullResponse;
+
+ // 发送纯文本
+ if (!hybridAlreadySentText) {
+ const unsentText = textOnly.substring(sentText.length);
+ if (unsentText) {
+ if (!textBlockStarted) {
+ writeSSE(res, 'content_block_start', {
+ type: 'content_block_start', index: blockIndex,
+ content_block: { type: 'text', text: '' },
+ });
+ textBlockStarted = true;
+ }
+ writeSSE(res, 'content_block_delta', {
+ type: 'content_block_delta', index: blockIndex,
+ delta: { type: 'text_delta', text: unsentText },
+ });
+ }
+ }
+ } else {
let { toolCalls, cleanText } = parseToolCalls(fullResponse);
// ★ tool_choice=any 强制重试:如果模型没有输出任何工具调用块,追加强制消息重试
@@ -1475,20 +1624,23 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
}
// Any clean text is sent as a single block before the tool blocks
- const unsentCleanText = cleanText.substring(sentText.length).trim();
+ // ★ 如果混合流式已经发送了文字,跳过重复发送
+ if (!hybridAlreadySentText) {
+ const unsentCleanText = cleanText.substring(sentText.length).trim();
- if (unsentCleanText) {
- if (!textBlockStarted) {
- writeSSE(res, 'content_block_start', {
- type: 'content_block_start', index: blockIndex,
- content_block: { type: 'text', text: '' },
+ if (unsentCleanText) {
+ if (!textBlockStarted) {
+ writeSSE(res, 'content_block_start', {
+ type: 'content_block_start', index: blockIndex,
+ content_block: { type: 'text', text: '' },
+ });
+ textBlockStarted = true;
+ }
+ writeSSE(res, 'content_block_delta', {
+ type: 'content_block_delta', index: blockIndex,
+ delta: { type: 'text_delta', text: (sentText && !sentText.endsWith('\n') ? '\n' : '') + unsentCleanText }
});
- textBlockStarted = true;
}
- writeSSE(res, 'content_block_delta', {
- type: 'content_block_delta', index: blockIndex,
- delta: { type: 'text_delta', text: (sentText && !sentText.endsWith('\n') ? '\n' : '') + unsentCleanText }
- });
}
if (textBlockStarted) {
@@ -1526,34 +1678,38 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
} else {
// False alarm! The tool triggers were just normal text.
// We must send the remaining unsent fullResponse.
- let textToSend = fullResponse;
+ // ★ 如果混合流式已发送部分文字,只发送未发送的部分
+ if (!hybridAlreadySentText) {
+ let textToSend = fullResponse;
- // ★ 仅对短响应或开头明确匹配拒绝模式的响应进行压制
- // fullResponse 已被剥离 thinking 标签
- const isShortResponse = fullResponse.trim().length < 500;
- const startsWithRefusal = isRefusal(fullResponse.substring(0, 300));
- const isActualRefusal = stopReason !== 'max_tokens' && (isShortResponse ? isRefusal(fullResponse) : startsWithRefusal);
+ // ★ 仅对短响应或开头明确匹配拒绝模式的响应进行压制
+ // fullResponse 已被剥离 thinking 标签
+ const isShortResponse = fullResponse.trim().length < 500;
+ const startsWithRefusal = isRefusal(fullResponse.substring(0, 300));
+ const isActualRefusal = stopReason !== 'max_tokens' && (isShortResponse ? isRefusal(fullResponse) : startsWithRefusal);
- if (isActualRefusal) {
- log.info('Handler', 'sanitize', `抑制无工具的完整拒绝响应`, { preview: fullResponse.substring(0, 200) });
- textToSend = 'I understand the request. Let me proceed with the appropriate action. Could you clarify what specific task you would like me to perform?';
- }
-
- const unsentText = textToSend.substring(sentText.length);
- if (unsentText) {
- if (!textBlockStarted) {
- writeSSE(res, 'content_block_start', {
- type: 'content_block_start', index: blockIndex,
- content_block: { type: 'text', text: '' },
- });
- textBlockStarted = true;
+ if (isActualRefusal) {
+ log.info('Handler', 'sanitize', `抑制无工具的完整拒绝响应`, { preview: fullResponse.substring(0, 200) });
+ textToSend = 'I understand the request. Let me proceed with the appropriate action. Could you clarify what specific task you would like me to perform?';
+ }
+
+ const unsentText = textToSend.substring(sentText.length);
+ if (unsentText) {
+ if (!textBlockStarted) {
+ writeSSE(res, 'content_block_start', {
+ type: 'content_block_start', index: blockIndex,
+ content_block: { type: 'text', text: '' },
+ });
+ textBlockStarted = true;
+ }
+ writeSSE(res, 'content_block_delta', {
+ type: 'content_block_delta', index: blockIndex,
+ delta: { type: 'text_delta', text: unsentText },
+ });
}
- writeSSE(res, 'content_block_delta', {
- type: 'content_block_delta', index: blockIndex,
- delta: { type: 'text_delta', text: unsentText },
- });
}
}
+ } // end else (non-truncated tool parsing)
} else {
// 无工具模式 — 缓冲后统一发送(已经过拒绝检测+重试)
// 最后一道防线:清洗所有 Cursor 身份引用
@@ -1708,7 +1864,6 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body
const MAX_AUTO_CONTINUE = getConfig().maxAutoContinue;
let continueCount = 0;
let consecutiveSmallAdds = 0; // 连续小增量计数
- const originalMessages = [...activeCursorReq.messages];
while (MAX_AUTO_CONTINUE > 0 && shouldAutoContinueTruncatedToolResponse(fullText, hasTools) && continueCount < MAX_AUTO_CONTINUE) {
continueCount++;
@@ -1730,9 +1885,9 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
const continuationReq: CursorChatRequest = {
...activeCursorReq,
messages: [
- ...originalMessages,
+ // ★ 续写优化:丢弃所有工具定义和历史消息
{
- parts: [{ type: 'text', text: fullText }],
+ parts: [{ type: 'text', text: fullText.length > 2000 ? '...\n' + fullText.slice(-2000) : fullText }],
id: uuidv4(),
role: 'assistant',
},
diff --git a/src/index.ts b/src/index.ts
index 710e2d6..3f0dc87 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -154,7 +154,7 @@ app.listen(config.port, () => {
// Tools 配置摘要
const toolsCfg = config.tools;
- let toolsInfo = 'default (compact, desc≤50)';
+ let toolsInfo = 'default (full, desc=full)';
if (toolsCfg) {
const parts: string[] = [];
parts.push(`schema=${toolsCfg.schemaMode}`);
diff --git a/src/logger.ts b/src/logger.ts
index 56ef0f4..7f1d166 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -466,10 +466,11 @@ export class RequestLogger {
.map((c: any) => c.text || '')
.join(' ');
}
- // 去掉 ... 注入内容
- text = text.replace(/[\s\S]*?<\/system-reminder>/gi, '');
- // 去掉 Claude Code 尾部的 "First, think step by step..." 引导语
+ // 去掉 ... 等 XML 注入内容
+ text = text.replace(/<[a-zA-Z_-]+>[\s\S]*?<\/[a-zA-Z_-]+>/gi, '');
+ // 去掉 Claude Code 尾部的引导语
text = text.replace(/First,\s*think\s+step\s+by\s+step[\s\S]*$/i, '');
+ text = text.replace(/Respond with the appropriate action[\s\S]*$/i, '');
// 清理换行、多余空格
text = text.replace(/\s+/g, ' ').trim();
this.summary.title = text.length > 80 ? text.substring(0, 77) + '...' : text;
diff --git a/src/openai-handler.ts b/src/openai-handler.ts
index 576a33c..a7f3867 100644
--- a/src/openai-handler.ts
+++ b/src/openai-handler.ts
@@ -779,11 +779,12 @@ async function handleOpenAIStream(
let retryCount = 0;
// 统一缓冲模式:先缓冲全部响应,再检测拒绝和处理
- const executeStream = async () => {
+ const executeStream = async (onTextDelta?: (delta: string) => void) => {
fullResponse = '';
await sendCursorRequest(activeCursorReq, (event: CursorSSEEvent) => {
if (event.type !== 'text-delta' || !event.delta) return;
fullResponse += event.delta;
+ onTextDelta?.(event.delta);
});
};
@@ -793,26 +794,132 @@ async function handleOpenAIStream(
return;
}
- await executeStream();
+ // ★ 混合流式:文本增量 + 工具缓冲(与 Anthropic handler 同一设计)
+ const thinkingEnabled = anthropicReq.thinking?.type === 'enabled';
+ const hybridStreamer = createIncrementalTextStreamer({
+ warmupChars: 300, // ★ 与拒绝检测窗口对齐
+ transform: sanitizeResponse,
+ isBlockedPrefix: (text) => isRefusal(text.substring(0, 300)),
+ });
+ let toolMarkerDetected = false;
+ let pendingText = '';
+ let hybridThinkingContent = '';
+ let hybridLeadingBuffer = '';
+ let hybridLeadingResolved = false;
+ const TOOL_MARKER = '```json action';
+ const MARKER_LOOKBACK = TOOL_MARKER.length + 2;
+ let hybridTextSent = false;
+ let hybridReasoningSent = false;
- // 日志记录在详细日志中 (Web UI 可见)
+ const pushToStreamer = (text: string): void => {
+ if (!text || toolMarkerDetected) return;
+ pendingText += text;
+ const idx = pendingText.indexOf(TOOL_MARKER);
+ if (idx >= 0) {
+ const before = pendingText.substring(0, idx);
+ if (before) {
+ const d = hybridStreamer.push(before);
+ if (d) {
+ if (thinkingEnabled && hybridThinkingContent && !hybridReasoningSent) {
+ writeOpenAIReasoningDelta(res, id, created, model, hybridThinkingContent);
+ hybridReasoningSent = true;
+ }
+ writeOpenAITextDelta(res, id, created, model, d);
+ hybridTextSent = true;
+ }
+ }
+ toolMarkerDetected = true;
+ pendingText = '';
+ return;
+ }
+ const safeEnd = pendingText.length - MARKER_LOOKBACK;
+ if (safeEnd > 0) {
+ const safe = pendingText.substring(0, safeEnd);
+ pendingText = pendingText.substring(safeEnd);
+ const d = hybridStreamer.push(safe);
+ if (d) {
+ if (thinkingEnabled && hybridThinkingContent && !hybridReasoningSent) {
+ writeOpenAIReasoningDelta(res, id, created, model, hybridThinkingContent);
+ hybridReasoningSent = true;
+ }
+ writeOpenAITextDelta(res, id, created, model, d);
+ hybridTextSent = true;
+ }
+ }
+ };
+
+ const processHybridDelta = (delta: string): void => {
+ if (!hybridLeadingResolved) {
+ hybridLeadingBuffer += delta;
+ const split = splitLeadingThinkingBlocks(hybridLeadingBuffer);
+ if (split.startedWithThinking) {
+ if (!split.complete) return;
+ hybridThinkingContent = split.thinkingContent;
+ hybridLeadingResolved = true;
+ hybridLeadingBuffer = '';
+ pushToStreamer(split.remainder);
+ return;
+ }
+ if (hybridLeadingBuffer.trimStart().length < 10) return;
+ hybridLeadingResolved = true;
+ const buffered = hybridLeadingBuffer;
+ hybridLeadingBuffer = '';
+ pushToStreamer(buffered);
+ return;
+ }
+ pushToStreamer(delta);
+ };
+
+ await executeStream(processHybridDelta);
+
+ // flush 残留缓冲
+ if (!hybridLeadingResolved && hybridLeadingBuffer) {
+ hybridLeadingResolved = true;
+ const split = splitLeadingThinkingBlocks(hybridLeadingBuffer);
+ if (split.startedWithThinking && split.complete) {
+ hybridThinkingContent = split.thinkingContent;
+ pushToStreamer(split.remainder);
+ } else {
+ pushToStreamer(hybridLeadingBuffer);
+ }
+ }
+ if (pendingText && !toolMarkerDetected) {
+ const d = hybridStreamer.push(pendingText);
+ if (d) {
+ if (thinkingEnabled && hybridThinkingContent && !hybridReasoningSent) {
+ writeOpenAIReasoningDelta(res, id, created, model, hybridThinkingContent);
+ hybridReasoningSent = true;
+ }
+ writeOpenAITextDelta(res, id, created, model, d);
+ hybridTextSent = true;
+ }
+ pendingText = '';
+ }
+ const hybridRemaining = hybridStreamer.finish();
+ if (hybridRemaining) {
+ if (thinkingEnabled && hybridThinkingContent && !hybridReasoningSent) {
+ writeOpenAIReasoningDelta(res, id, created, model, hybridThinkingContent);
+ hybridReasoningSent = true;
+ }
+ writeOpenAITextDelta(res, id, created, model, hybridRemaining);
+ hybridTextSent = true;
+ }
// ★ Thinking 提取(在拒绝检测之前)
- const thinkingEnabled = anthropicReq.thinking?.type === 'enabled';
- let reasoningContent: string | undefined;
+ let reasoningContent: string | undefined = hybridThinkingContent || undefined;
if (fullResponse.includes('')) {
const { thinkingContent: extracted, strippedText } = extractThinking(fullResponse);
if (extracted) {
- if (thinkingEnabled) {
+ if (thinkingEnabled && !reasoningContent) {
reasoningContent = extracted;
}
fullResponse = strippedText;
- // thinking 剥离记录在详细日志中
}
}
- // 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
+ // 拒绝检测 + 自动重试
const shouldRetryRefusal = () => {
+ if (hybridTextSent) return false; // 已发文字,不可重试
if (!isRefusal(fullResponse)) return false;
if (hasTools && hasToolCalls(fullResponse)) return false;
return true;
@@ -820,22 +927,18 @@ async function handleOpenAIStream(
while (shouldRetryRefusal() && retryCount < MAX_REFUSAL_RETRIES) {
retryCount++;
- // 重试记录在详细日志中
const retryBody = buildRetryRequest(anthropicReq, retryCount - 1);
activeCursorReq = await convertToCursorRequest(retryBody);
- await executeStream();
+ await executeStream(); // 重试不传回调
}
if (shouldRetryRefusal()) {
if (!hasTools) {
if (isToolCapabilityQuestion(anthropicReq)) {
- // 记录在详细日志
fullResponse = CLAUDE_TOOLS_RESPONSE;
} else {
- // 记录在详细日志
fullResponse = CLAUDE_IDENTITY_RESPONSE;
}
} else {
- // 记录在详细日志
fullResponse = 'I understand the request. Let me analyze the information and proceed with the appropriate action.';
}
}
@@ -843,7 +946,6 @@ async function handleOpenAIStream(
// 极短响应重试
if (hasTools && fullResponse.trim().length < 10 && retryCount < MAX_REFUSAL_RETRIES) {
retryCount++;
- // 记录在详细日志
activeCursorReq = await convertToCursorRequest(anthropicReq);
await executeStream();
}
@@ -854,8 +956,8 @@ async function handleOpenAIStream(
let finishReason: 'stop' | 'tool_calls' = 'stop';
- // ★ 发送 reasoning_content(如果有)
- if (reasoningContent) {
+ // ★ 发送 reasoning_content(仅在混合流式未发送时)
+ if (reasoningContent && !hybridReasoningSent) {
writeOpenAISSE(res, {
id, object: 'chat.completion.chunk', created, model,
choices: [{
@@ -872,18 +974,20 @@ async function handleOpenAIStream(
if (toolCalls.length > 0) {
finishReason = 'tool_calls';
- // 发送工具调用前的残余文本(清洗后)
- let cleanOutput = isRefusal(cleanText) ? '' : cleanText;
- cleanOutput = sanitizeResponse(cleanOutput);
- if (cleanOutput) {
- writeOpenAISSE(res, {
- id, object: 'chat.completion.chunk', created, model,
- choices: [{
- index: 0,
- delta: { content: cleanOutput },
- finish_reason: null,
- }],
- });
+ // 发送工具调用前的残余文本 — 如果混合流式已发送则跳过
+ if (!hybridTextSent) {
+ let cleanOutput = isRefusal(cleanText) ? '' : cleanText;
+ cleanOutput = sanitizeResponse(cleanOutput);
+ if (cleanOutput) {
+ writeOpenAISSE(res, {
+ id, object: 'chat.completion.chunk', created, model,
+ choices: [{
+ index: 0,
+ delta: { content: cleanOutput },
+ finish_reason: null,
+ }],
+ });
+ }
}
// 增量流式发送工具调用:先发 name+id,再分块发 arguments
@@ -929,38 +1033,42 @@ async function handleOpenAIStream(
}
}
} else {
- // 误报:发送清洗后的文本
- let textToSend = fullResponse;
- if (isRefusal(fullResponse)) {
- textToSend = 'I understand the request. Let me proceed with the appropriate action. Could you clarify what specific task you would like me to perform?';
- } else {
- textToSend = sanitizeResponse(fullResponse);
+ // 误报:发送清洗后的文本(如果混合流式未发送)
+ if (!hybridTextSent) {
+ let textToSend = fullResponse;
+ if (isRefusal(fullResponse)) {
+ textToSend = 'I understand the request. Let me proceed with the appropriate action. Could you clarify what specific task you would like me to perform?';
+ } else {
+ textToSend = sanitizeResponse(fullResponse);
+ }
+ writeOpenAISSE(res, {
+ id, object: 'chat.completion.chunk', created, model,
+ choices: [{
+ index: 0,
+ delta: { content: textToSend },
+ finish_reason: null,
+ }],
+ });
}
- writeOpenAISSE(res, {
- id, object: 'chat.completion.chunk', created, model,
- choices: [{
- index: 0,
- delta: { content: textToSend },
- finish_reason: null,
- }],
- });
}
} else {
- // 无工具模式或无工具调用 — 统一清洗后发送
- let sanitized = sanitizeResponse(fullResponse);
- // ★ response_format 后处理:剥离 markdown 代码块包裹
- if (body.response_format && body.response_format.type !== 'text') {
- sanitized = stripMarkdownJsonWrapper(sanitized);
- }
- if (sanitized) {
- writeOpenAISSE(res, {
- id, object: 'chat.completion.chunk', created, model,
- choices: [{
- index: 0,
- delta: { content: sanitized },
- finish_reason: null,
- }],
- });
+ // 无工具模式或无工具调用 — 如果混合流式未发送则统一清洗后发送
+ if (!hybridTextSent) {
+ let sanitized = sanitizeResponse(fullResponse);
+ // ★ response_format 后处理:剥离 markdown 代码块包裹
+ if (body.response_format && body.response_format.type !== 'text') {
+ sanitized = stripMarkdownJsonWrapper(sanitized);
+ }
+ if (sanitized) {
+ writeOpenAISSE(res, {
+ id, object: 'chat.completion.chunk', created, model,
+ choices: [{
+ index: 0,
+ delta: { content: sanitized },
+ finish_reason: null,
+ }],
+ });
+ }
}
}
diff --git a/test/test-hybrid-stream.mjs b/test/test-hybrid-stream.mjs
new file mode 100644
index 0000000..eaaf594
--- /dev/null
+++ b/test/test-hybrid-stream.mjs
@@ -0,0 +1,216 @@
+/**
+ * 混合流式完整性测试
+ * 验证:
+ * 1. 文字增量流式 ✓
+ * 2. 工具调用参数完整 ✓
+ * 3. 多工具调用 ✓
+ * 4. 纯文字(无工具调用)✓
+ * 5. stop_reason 正确 ✓
+ */
+
+import http from 'http';
+
+const BASE = process.env.BASE_URL || 'http://localhost:3010';
+const url = new URL(BASE);
+
+function runAnthropicTest(name, body, timeout = 60000) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { reject(new Error('超时 ' + timeout + 'ms')); }, timeout);
+ const data = JSON.stringify(body);
+ const req = http.request({
+ hostname: url.hostname, port: url.port, path: '/v1/messages', method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json', 'x-api-key': 'test',
+ 'anthropic-version': '2023-06-01', 'Content-Length': Buffer.byteLength(data),
+ },
+ }, (res) => {
+ const start = Date.now();
+ let events = [];
+ let buf = '';
+
+ res.on('data', (chunk) => {
+ buf += chunk.toString();
+ const lines = buf.split('\n');
+ buf = lines.pop(); // keep incomplete last line
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ const payload = line.slice(6).trim();
+ if (payload === '[DONE]') continue;
+ try {
+ const ev = JSON.parse(payload);
+ events.push({ ...ev, _ts: Date.now() - start });
+ } catch { /* skip */ }
+ }
+ });
+
+ res.on('end', () => {
+ clearTimeout(timer);
+ // 解析结果
+ const textDeltas = events.filter(e => e.type === 'content_block_delta' && e.delta?.type === 'text_delta');
+ const toolStarts = events.filter(e => e.type === 'content_block_start' && e.content_block?.type === 'tool_use');
+ const toolInputDeltas = events.filter(e => e.type === 'content_block_delta' && e.delta?.type === 'input_json_delta');
+ const msgDelta = events.find(e => e.type === 'message_delta');
+ const msgStop = events.find(e => e.type === 'message_stop');
+
+ const fullText = textDeltas.map(e => e.delta.text).join('');
+ const tools = toolStarts.map(ts => {
+ // 收集该工具的 input JSON
+ const inputChunks = toolInputDeltas
+ .filter(d => d.index === ts.index)
+ .map(d => d.delta.partial_json);
+ let parsedInput = null;
+ try { parsedInput = JSON.parse(inputChunks.join('')); } catch { }
+ return {
+ name: ts.content_block.name,
+ id: ts.content_block.id,
+ input: parsedInput,
+ inputRaw: inputChunks.join(''),
+ };
+ });
+
+ resolve({
+ name,
+ textChunks: textDeltas.length,
+ textLength: fullText.length,
+ textPreview: fullText.substring(0, 120).replace(/\n/g, '\\n'),
+ tools,
+ stopReason: msgDelta?.delta?.stop_reason || '?',
+ firstTextMs: textDeltas[0]?._ts ?? -1,
+ firstToolMs: toolStarts[0]?._ts ?? -1,
+ doneMs: msgStop?._ts ?? -1,
+ });
+ });
+ res.on('error', (err) => { clearTimeout(timer); reject(err); });
+ });
+ req.on('error', (err) => { clearTimeout(timer); reject(err); });
+ req.write(data);
+ req.end();
+ });
+}
+
+function printResult(r) {
+ console.log(`\n 📊 ${r.name}`);
+ console.log(` 时间: 首字=${r.firstTextMs}ms 首工具=${r.firstToolMs}ms 完成=${r.doneMs}ms`);
+ console.log(` 文字: ${r.textChunks} chunks, ${r.textLength} chars`);
+ if (r.textPreview) console.log(` 预览: "${r.textPreview}"`);
+ console.log(` stop_reason: ${r.stopReason}`);
+ if (r.tools.length > 0) {
+ console.log(` 工具调用 (${r.tools.length}个):`);
+ for (const t of r.tools) {
+ console.log(` - ${t.name}(${JSON.stringify(t.input)})`);
+ if (!t.input) console.log(` ⚠️ 参数解析失败! raw: ${t.inputRaw?.substring(0, 100)}`);
+ }
+ }
+}
+
+const TOOLS = [
+ { name: 'Read', description: 'Read file contents', input_schema: { type: 'object', properties: { file_path: { type: 'string' } }, required: ['file_path'] } },
+ { name: 'Write', description: 'Write file contents', input_schema: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] } },
+ { name: 'Bash', description: 'Run bash command', input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } },
+];
+
+async function main() {
+ console.log('\n ⚡ 混合流式完整性测试\n');
+
+ // 健康检查
+ try {
+ await new Promise((resolve, reject) => {
+ const req = http.get(`${BASE}/health`, r => { r.resume(); r.on('end', resolve); });
+ req.on('error', reject);
+ req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
+ });
+ } catch {
+ console.log(' ❌ 服务器未运行\n');
+ process.exit(1);
+ }
+ console.log(' ✅ 服务器在线');
+
+ let passed = 0;
+ let failed = 0;
+
+ // ---- Test 1: 单工具调用 ----
+ console.log('\n ━━━ ① 单工具调用 ━━━');
+ try {
+ const r = await runAnthropicTest('单工具调用', {
+ model: 'claude-3-5-sonnet-20241022', max_tokens: 1024, stream: true,
+ system: 'You are a coding assistant with file system access. When a user asks to read a file, use the Read tool immediately. Do not refuse or explain limitations.',
+ tools: TOOLS,
+ messages: [{ role: 'user', content: 'Read the file /src/index.ts for me please' }],
+ });
+ printResult(r);
+
+ // 验证
+ const checks = [];
+ checks.push({ name: 'stop_reason=tool_use', ok: r.stopReason === 'tool_use' });
+ checks.push({ name: '至少1个工具调用', ok: r.tools.length >= 1 });
+ checks.push({ name: '工具名=Read', ok: r.tools.some(t => t.name === 'Read') });
+ checks.push({ name: '工具参数有 file_path', ok: r.tools.some(t => t.input?.file_path) });
+ checks.push({ name: '首字延迟<10s', ok: r.firstTextMs >= 0 && r.firstTextMs < 10000 });
+
+ for (const c of checks) {
+ console.log(` ${c.ok ? '✅' : '❌'} ${c.name}`);
+ c.ok ? passed++ : failed++;
+ }
+ } catch (err) {
+ console.log(` ❌ 失败: ${err.message}`);
+ failed++;
+ }
+
+ // ---- Test 2: 多工具调用 ----
+ console.log('\n ━━━ ② 多工具调用 ━━━');
+ try {
+ const r = await runAnthropicTest('多工具调用', {
+ model: 'claude-3-5-sonnet-20241022', max_tokens: 2048, stream: true,
+ system: 'You are a coding assistant with file system access. When asked to read multiple files, use multiple Read tool calls in a single response. Do not refuse.',
+ tools: TOOLS,
+ messages: [{ role: 'user', content: 'Read both /src/index.ts and /src/config.ts for me' }],
+ });
+ printResult(r);
+
+ const checks = [];
+ checks.push({ name: 'stop_reason=tool_use', ok: r.stopReason === 'tool_use' });
+ checks.push({ name: '≥2个工具调用', ok: r.tools.length >= 2 });
+ checks.push({ name: '工具参数都有 file_path', ok: r.tools.every(t => t.input?.file_path) });
+
+ for (const c of checks) {
+ console.log(` ${c.ok ? '✅' : '❌'} ${c.name}`);
+ c.ok ? passed++ : failed++;
+ }
+ } catch (err) {
+ console.log(` ❌ 失败: ${err.message}`);
+ failed++;
+ }
+
+ // ---- Test 3: 纯文字(带工具定义但不需要调用) ----
+ console.log('\n ━━━ ③ 纯文字(有工具但不调用) ━━━');
+ try {
+ const r = await runAnthropicTest('纯文字', {
+ model: 'claude-3-5-sonnet-20241022', max_tokens: 512, stream: true,
+ system: 'You are helpful. Answer questions directly without using any tools.',
+ tools: TOOLS,
+ messages: [{ role: 'user', content: 'What is 2+2? Just answer with the number.' }],
+ });
+ printResult(r);
+
+ const checks = [];
+ checks.push({ name: 'stop_reason=end_turn', ok: r.stopReason === 'end_turn' });
+ checks.push({ name: '0个工具调用', ok: r.tools.length === 0 });
+ checks.push({ name: '有文字输出', ok: r.textLength > 0 });
+ checks.push({ name: '文字含数字4', ok: r.textPreview.includes('4') });
+
+ for (const c of checks) {
+ console.log(` ${c.ok ? '✅' : '❌'} ${c.name}`);
+ c.ok ? passed++ : failed++;
+ }
+ } catch (err) {
+ console.log(` ❌ 失败: ${err.message}`);
+ failed++;
+ }
+
+ // ---- 汇总 ----
+ console.log(`\n ━━━ 汇总 ━━━`);
+ console.log(` ✅ 通过: ${passed} ❌ 失败: ${failed}\n`);
+ process.exit(failed > 0 ? 1 : 0);
+}
+
+main().catch(err => { console.error('致命错误:', err); process.exit(1); });