mirror of
https://github.com/7836246/cursor2api.git
synced 2026-05-07 22:27:15 +08:00
release: v2.7.3 — 统一 thinking 剥离 + 拒绝检测增强 + Docker 部署优化
Made-with: Cursor
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ node_modules/
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -33,14 +33,18 @@ RUN npm ci --omit=dev \
|
||||
# 从 builder 阶段拷贝编译后的产物
|
||||
COPY --from=builder --chown=cursor:nodejs /app/dist ./dist
|
||||
|
||||
# 拷贝默认配置文件(可通过 volume 挂载覆盖)
|
||||
COPY --chown=cursor:nodejs config.yaml ./config.yaml
|
||||
# 创建日志目录并授权
|
||||
RUN mkdir -p /app/logs && chown cursor:nodejs /app/logs
|
||||
|
||||
# 注意:config.yaml 不打包进镜像,通过 docker-compose volumes 挂载
|
||||
# 如果未挂载,服务会使用内置默认值 + 环境变量
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER cursor
|
||||
|
||||
# 声明对外暴露的端口
|
||||
# 声明对外暴露的端口和持久化卷
|
||||
EXPOSE 3010
|
||||
VOLUME ["/app/logs"]
|
||||
|
||||
# 启动服务
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Cursor2API v2.7.2
|
||||
# Cursor2API v2.7.3
|
||||
|
||||
将 Cursor 文档页免费 AI 对话接口代理转换为 **Anthropic Messages API** 和 **OpenAI Chat Completions API**,支持 **Claude Code** 和 **Cursor IDE** 使用。
|
||||
|
||||
> ⚠️ **版本说明**:当前 v2.7.2 新增日志查看器日夜主题切换、标题提取修复、配置模板化。
|
||||
> ⚠️ **版本说明**:当前 v2.7.3 统一 thinking 剥离逻辑、增强拒绝检测准确性、优化 Docker 部署配置。
|
||||
|
||||
## 原理
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ services:
|
||||
ports:
|
||||
- "3010:3010"
|
||||
volumes:
|
||||
# 挂载外部配置文件(推荐)——修改后只需 docker compose restart 即可生效
|
||||
# 挂载配置文件(可选)——先从 config.yaml.example 复制一份: cp config.yaml.example config.yaml
|
||||
# 修改后只需 docker compose restart 即可生效;不挂载则使用内置默认值 + 环境变量
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
# 日志持久化目录(需要在 config.yaml 或环境变量中开启 logging.file_enabled)
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3010
|
||||
@@ -21,6 +24,25 @@ services:
|
||||
# [可选环境变量] 以下变量如果声明,将会覆盖 config.yaml 中对应的配置:
|
||||
# - CURSOR_MODEL=anthropic/claude-sonnet-4.6
|
||||
|
||||
# ── Vision 图片处理(v2.3.0 新增) ──
|
||||
# ── API 鉴权 ──
|
||||
# 公网部署时强烈建议开启,多个 token 用逗号分隔
|
||||
# - AUTH_TOKEN=sk-your-secret-token-1,sk-your-secret-token-2
|
||||
|
||||
# ── Thinking 开关(最高优先级,覆盖 config.yaml) ──
|
||||
# true=始终启用思考链, false=强制关闭
|
||||
# - THINKING_ENABLED=true
|
||||
|
||||
# ── 历史消息压缩 ──
|
||||
# - COMPRESSION_ENABLED=true
|
||||
# - COMPRESSION_LEVEL=2
|
||||
|
||||
# ── 日志持久化 ──
|
||||
# - LOG_FILE_ENABLED=true
|
||||
# - LOG_DIR=./logs
|
||||
|
||||
# ── 浏览器指纹(base64 JSON) ──
|
||||
# - FP=eyJ1c2VyQWdlbnQiOiIuLi4ifQ==
|
||||
|
||||
# ── Vision 图片处理 ──
|
||||
# 默认使用本地 OCR(零配置),如需外部 Vision API 请在 config.yaml 中修改 vision.mode 为 'api'
|
||||
# 并配置 vision.base_url / vision.api_key / vision.model
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor2api",
|
||||
"version": "2.7.2",
|
||||
"version": "2.7.3",
|
||||
"description": "Proxy Cursor docs AI to Anthropic Messages API for Claude Code",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -289,7 +289,7 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise<Cur
|
||||
if (!text) continue;
|
||||
|
||||
// 清洗历史中的拒绝痕迹,防止上下文连锁拒绝
|
||||
if (/\[System\s+Filter\]|Cursor(?:'s)?\s+support\s+assistant|I['']\s*m\s+sorry|not\s+able\s+to\s+fulfill|I\s+cannot\s+help\s+with|I\s+only\s+answer\s+questions\s+about\s+Cursor|injected\s+system\s+prompts|I\s+don't\s+have\s+permission|haven't\s+granted|I'm\s+a\s+coding\s+assistant|focused\s+on\s+software\s+development|beyond\s+(?:my|the)\s+scope|I'?m\s+not\s+(?:able|designed)\s+to|not\s+able\s+to\s+search|I\s+cannot\s+search|prompt\s+injection|social\s+engineering|What\s+I\s+will\s+not\s+do|What\s+is\s+actually\s+happening|I\s+need\s+to\s+stop\s+and\s+flag|replayed\s+against|copy-pasteable|tool-call\s+payloads|I\s+will\s+not\s+do|不是.*需要文档化|工具调用场景|语言偏好请求|具体场景|无法调用|即报错/i.test(text)) {
|
||||
if (/\[System\s+Filter\]|Cursor(?:'s)?\s+support\s+assistant|I['']\s*m\s+sorry|not\s+able\s+to\s+fulfill|I\s+cannot\s+help\s+with|I\s+only\s+answer\s+questions\s+about\s+Cursor|injected\s+system\s+prompts|I\s+don't\s+have\s+permission|haven't\s+granted|I'm\s+a\s+coding\s+assistant|focused\s+on\s+software\s+development|beyond\s+(?:my|the)\s+scope|I'?m\s+not\s+(?:able|designed)\s+to|not\s+able\s+to\s+search|I\s+cannot\s+search|prompt\s+injection|social\s+engineering|What\s+I\s+will\s+not\s+do|What\s+is\s+actually\s+happening|I\s+need\s+to\s+stop\s+and\s+flag|replayed\s+against|copy-pasteable|tool-call\s+payloads|I\s+will\s+not\s+do|不是.*需要文档化|工具调用场景|语言偏好请求|具体场景|无法调用|即报错|accidentally\s+(?:called|calling)|Cursor\s+documentation/i.test(text)) {
|
||||
text = `\`\`\`json action\n${JSON.stringify({ tool: fewShotTool.name, parameters: fewShotParams }, null, 2)}\n\`\`\``;
|
||||
}
|
||||
|
||||
|
||||
135
src/handler.ts
135
src/handler.ts
@@ -396,7 +396,13 @@ export function sanitizeResponse(text: string): string {
|
||||
result = result.replace(/\*\*`?read_dir`?\*\*[^\n]*\n(?:[^\n]*\n){0,3}/gi, '');
|
||||
result = result.replace(/\d+\.\s*\*\*`?read_(?:file|dir)`?\*\*[^\n]*/gi, '');
|
||||
result = result.replace(/[⚠注意].*?(?:不是|并非|无法).*?(?:本地文件|代码库|执行代码)[^。\n]*[。]?\s*/g, '');
|
||||
|
||||
// === Hallucination about accidentally calling Cursor internal tools ===
|
||||
// "I accidentally called the Cursor documentation read_dir tool." -> remove entire sentence
|
||||
result = result.replace(/[^\n.!?]*(?:accidentally|mistakenly|keep|sorry|apologies|apologize)[^\n.!?]*(?:called|calling|used|using)[^\n.!?]*Cursor[^\n.!?]*tool[^\n.!?]*[.!?]\s*/gi, '');
|
||||
result = result.replace(/[^\n.!?]*Cursor\s+documentation[^\n.!?]*tool[^\n.!?]*[.!?]\s*/gi, '');
|
||||
// Sometimes it follows up with "I need to stop this." -> remove if preceding tool hallucination
|
||||
result = result.replace(/I\s+need\s+to\s+stop\s+this[.!]\s*/gi, '');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -747,7 +753,7 @@ async function handleDirectTextStream(
|
||||
let rawResponse = '';
|
||||
let visibleText = '';
|
||||
let leadingBuffer = '';
|
||||
let leadingResolved = !clientRequestedThinking;
|
||||
let leadingResolved = false;
|
||||
let thinkingContent = '';
|
||||
const attemptStreamer = createIncrementalTextStreamer({
|
||||
transform: sanitizeResponse,
|
||||
@@ -782,11 +788,9 @@ async function handleDirectTextStream(
|
||||
|
||||
rawResponse += event.delta;
|
||||
|
||||
if (!clientRequestedThinking) {
|
||||
flushVisible(event.delta);
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 始终缓冲前导内容以检测并剥离 <thinking> 标签
|
||||
// 无论 clientRequestedThinking 是否为 true,都需要分离 thinking
|
||||
// 区别在于:true 时发送 thinking content block,false 时静默丢弃 thinking 标签
|
||||
if (!leadingResolved) {
|
||||
leadingBuffer += event.delta;
|
||||
const split = splitLeadingThinkingBlocks(leadingBuffer);
|
||||
@@ -800,6 +804,12 @@ async function handleDirectTextStream(
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有以 <thinking> 开头:检查缓冲区是否足够判断
|
||||
// 如果缓冲区还很短(< "<thinking>".length),继续等待
|
||||
if (leadingBuffer.trimStart().length < THINKING_OPEN.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
leadingResolved = true;
|
||||
const buffered = leadingBuffer;
|
||||
leadingBuffer = '';
|
||||
@@ -810,6 +820,21 @@ async function handleDirectTextStream(
|
||||
flushVisible(event.delta);
|
||||
});
|
||||
|
||||
// ★ 流结束后 flush 残留的 leadingBuffer
|
||||
// 极短响应可能在 leadingBuffer 中有未发送的内容
|
||||
if (!leadingResolved && leadingBuffer) {
|
||||
leadingResolved = true;
|
||||
// 再次尝试分离 thinking(完整响应可能包含完整的 thinking 块)
|
||||
const split = splitLeadingThinkingBlocks(leadingBuffer);
|
||||
if (split.startedWithThinking && split.complete) {
|
||||
thinkingContent = split.thinkingContent;
|
||||
flushVisible(split.remainder);
|
||||
} else {
|
||||
flushVisible(leadingBuffer);
|
||||
}
|
||||
leadingBuffer = '';
|
||||
}
|
||||
|
||||
if (firstChunk) {
|
||||
log.endPhase();
|
||||
} else {
|
||||
@@ -820,7 +845,7 @@ async function handleDirectTextStream(
|
||||
|
||||
return {
|
||||
rawResponse,
|
||||
visibleText: clientRequestedThinking ? visibleText : rawResponse,
|
||||
visibleText,
|
||||
thinkingContent,
|
||||
streamer: attemptStreamer,
|
||||
};
|
||||
@@ -833,14 +858,11 @@ async function handleDirectTextStream(
|
||||
finalThinkingContent = attempt.thinkingContent;
|
||||
streamer = attempt.streamer;
|
||||
|
||||
const textForRefusalCheck = clientRequestedThinking
|
||||
? finalVisibleText
|
||||
: stripThinkingTags(finalRawResponse);
|
||||
|
||||
if (!streamer.hasSentText() && isRefusal(textForRefusalCheck) && retryCount < MAX_REFUSAL_RETRIES) {
|
||||
// visibleText 始终是剥离 thinking 后的文本,可直接用于拒绝检测
|
||||
if (!streamer.hasSentText() && isRefusal(finalVisibleText) && retryCount < MAX_REFUSAL_RETRIES) {
|
||||
retryCount++;
|
||||
log.warn('Handler', 'retry', `检测到拒绝(第${retryCount}次),自动重试`, {
|
||||
preview: textForRefusalCheck.substring(0, 200),
|
||||
preview: finalVisibleText.substring(0, 200),
|
||||
});
|
||||
log.updateSummary({ retryCount });
|
||||
const retryBody = buildRetryRequest(body, retryCount - 1);
|
||||
@@ -867,16 +889,12 @@ async function handleDirectTextStream(
|
||||
if (finalThinkingContent) {
|
||||
log.recordThinking(finalThinkingContent);
|
||||
log.updateSummary({ thinkingChars: finalThinkingContent.length });
|
||||
if (clientRequestedThinking) {
|
||||
log.info('Handler', 'thinking', `剥离 thinking → content block: ${finalThinkingContent.length} chars, 剩余 ${finalVisibleText.length} chars`);
|
||||
} else {
|
||||
log.info('Handler', 'thinking', `保留 thinking 在正文中 (非客户端请求): ${finalThinkingContent.length} chars`);
|
||||
}
|
||||
log.info('Handler', 'thinking', `剥离 thinking: ${finalThinkingContent.length} chars, 剩余正文 ${finalVisibleText.length} chars, clientRequested=${clientRequestedThinking}`);
|
||||
}
|
||||
|
||||
let finalTextToSend: string;
|
||||
const refusalText = clientRequestedThinking ? finalVisibleText : stripThinkingTags(finalRawResponse);
|
||||
const usedFallback = !streamer.hasSentText() && isRefusal(refusalText);
|
||||
// visibleText 现在始终是剥离 thinking 后的文本
|
||||
const usedFallback = !streamer.hasSentText() && isRefusal(finalVisibleText);
|
||||
if (usedFallback) {
|
||||
if (isToolCapabilityQuestion(body)) {
|
||||
log.info('Handler', 'refusal', '工具能力询问被拒绝 → 返回 Claude 能力描述');
|
||||
@@ -911,7 +929,7 @@ async function handleDirectTextStream(
|
||||
writeSSE(res, 'message_stop', { type: 'message_stop' });
|
||||
|
||||
const finalRecordedResponse = streamer.hasSentText()
|
||||
? sanitizeResponse(clientRequestedThinking ? finalVisibleText : finalRawResponse)
|
||||
? sanitizeResponse(finalVisibleText)
|
||||
: finalTextToSend;
|
||||
log.recordFinalResponse(finalRecordedResponse);
|
||||
log.complete(finalRecordedResponse.length, 'end_turn');
|
||||
@@ -1003,36 +1021,27 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
|
||||
});
|
||||
|
||||
// ★ Thinking 提取(在拒绝检测之前,防止 thinking 内容触发 isRefusal 误判)
|
||||
// 始终剥离 thinking 标签,避免泄漏到最终文本中
|
||||
let thinkingContent = '';
|
||||
if (fullResponse.includes('<thinking>')) {
|
||||
const { thinkingContent: extracted, strippedText } = extractThinking(fullResponse);
|
||||
if (extracted) {
|
||||
thinkingContent = extracted;
|
||||
fullResponse = strippedText;
|
||||
log.recordThinking(thinkingContent);
|
||||
log.updateSummary({ thinkingChars: thinkingContent.length });
|
||||
if (clientRequestedThinking) {
|
||||
// 客户端原生请求 thinking → 剥离标签,稍后发送 thinking content block
|
||||
fullResponse = strippedText;
|
||||
log.info('Handler', 'thinking', `剥离 thinking → content block: ${thinkingContent.length} chars, 剩余 ${fullResponse.length} chars`);
|
||||
} else {
|
||||
// proxy 注入的 thinking → 保留标签在正文中,Claude Code 可直接显示
|
||||
log.info('Handler', 'thinking', `保留 thinking 在正文中 (非客户端请求): ${thinkingContent.length} chars`);
|
||||
log.info('Handler', 'thinking', `剥离 thinking (非客户端请求): ${thinkingContent.length} chars, 剩余 ${fullResponse.length} chars`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
|
||||
// ★ 关键:拒绝检测必须在 thinking-stripped 文本上进行
|
||||
// 否则 thinking 中的反思性语言(如 "haven't given a specific task")会触发误判
|
||||
const getTextForRefusalCheck = () => {
|
||||
if (fullResponse.includes('<thinking>')) {
|
||||
return extractThinking(fullResponse).strippedText;
|
||||
}
|
||||
return fullResponse;
|
||||
};
|
||||
// 拒绝检测 + 自动重试
|
||||
// fullResponse 已在上方剥离 thinking 标签,可直接用于拒绝检测
|
||||
const shouldRetryRefusal = () => {
|
||||
const textToCheck = getTextForRefusalCheck();
|
||||
if (!isRefusal(textToCheck)) return false;
|
||||
if (!isRefusal(fullResponse)) return false;
|
||||
if (hasTools && hasToolCalls(fullResponse)) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -1044,6 +1053,14 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
|
||||
const retryBody = buildRetryRequest(body, retryCount - 1);
|
||||
activeCursorReq = await convertToCursorRequest(retryBody);
|
||||
await executeStream();
|
||||
// 重试后也需要剥离 thinking 标签
|
||||
if (fullResponse.includes('<thinking>')) {
|
||||
const { thinkingContent: retryThinking, strippedText: retryStripped } = extractThinking(fullResponse);
|
||||
if (retryThinking) {
|
||||
thinkingContent = retryThinking;
|
||||
fullResponse = retryStripped;
|
||||
}
|
||||
}
|
||||
log.info('Handler', 'retry', `重试响应: ${fullResponse.length} chars`, { preview: fullResponse.substring(0, 200) });
|
||||
}
|
||||
|
||||
@@ -1294,12 +1311,10 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
|
||||
let textToSend = fullResponse;
|
||||
|
||||
// ★ 仅对短响应或开头明确匹配拒绝模式的响应进行压制
|
||||
// 长响应(如模型在写报告)中可能碰巧包含某个宽泛的拒绝关键词,不应被误判
|
||||
// 截断响应(stopReason=max_tokens)一定不是拒绝
|
||||
const strippedResponse = getTextForRefusalCheck();
|
||||
const isShortResponse = strippedResponse.trim().length < 500;
|
||||
const startsWithRefusal = isRefusal(strippedResponse.substring(0, 300));
|
||||
const isActualRefusal = stopReason !== 'max_tokens' && (isShortResponse ? isRefusal(strippedResponse) : 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) });
|
||||
@@ -1407,31 +1422,25 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body
|
||||
});
|
||||
|
||||
// ★ Thinking 提取(在拒绝检测之前)
|
||||
// 始终剥离 thinking 标签,避免泄漏到最终文本中
|
||||
let thinkingContent = '';
|
||||
if (fullText.includes('<thinking>')) {
|
||||
const { thinkingContent: extracted, strippedText } = extractThinking(fullText);
|
||||
if (extracted) {
|
||||
thinkingContent = extracted;
|
||||
fullText = strippedText;
|
||||
if (clientRequestedThinking) {
|
||||
fullText = strippedText;
|
||||
log.info('Handler', 'thinking', `非流式剥离 thinking → content block: ${thinkingContent.length} chars, 剩余 ${fullText.length} chars`);
|
||||
} else {
|
||||
log.info('Handler', 'thinking', `非流式保留 thinking 在正文中: ${thinkingContent.length} chars`);
|
||||
log.info('Handler', 'thinking', `非流式剥离 thinking (非客户端请求): ${thinkingContent.length} chars, 剩余 ${fullText.length} chars`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
|
||||
// ★ 关键:拒绝检测必须在 thinking-stripped 文本上进行
|
||||
const getTextForRefusalCheck = () => {
|
||||
if (fullText.includes('<thinking>')) {
|
||||
return extractThinking(fullText).strippedText;
|
||||
}
|
||||
return fullText;
|
||||
};
|
||||
// 拒绝检测 + 自动重试
|
||||
// fullText 已在上方剥离 thinking 标签,可直接用于拒绝检测
|
||||
const shouldRetry = () => {
|
||||
const textToCheck = getTextForRefusalCheck();
|
||||
return isRefusal(textToCheck) && !(hasTools && hasToolCalls(fullText));
|
||||
return isRefusal(fullText) && !(hasTools && hasToolCalls(fullText));
|
||||
};
|
||||
|
||||
if (shouldRetry()) {
|
||||
@@ -1442,6 +1451,14 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body
|
||||
const retryBody = buildRetryRequest(body, attempt);
|
||||
activeCursorReq = await convertToCursorRequest(retryBody);
|
||||
fullText = await sendCursorRequestFull(activeCursorReq);
|
||||
// 重试后也需要剥离 thinking 标签
|
||||
if (fullText.includes('<thinking>')) {
|
||||
const { thinkingContent: retryThinking, strippedText: retryStripped } = extractThinking(fullText);
|
||||
if (retryThinking) {
|
||||
thinkingContent = retryThinking;
|
||||
fullText = retryStripped;
|
||||
}
|
||||
}
|
||||
if (!shouldRetry()) break;
|
||||
}
|
||||
if (shouldRetry()) {
|
||||
@@ -1623,10 +1640,10 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
|
||||
} else {
|
||||
let textToSend = fullText;
|
||||
// ★ 同样仅对短响应或开头匹配的进行拒绝压制
|
||||
const strippedText = getTextForRefusalCheck();
|
||||
const isShort = strippedText.trim().length < 500;
|
||||
const startsRefusal = isRefusal(strippedText.substring(0, 300));
|
||||
const isRealRefusal = stopReason !== 'max_tokens' && (isShort ? isRefusal(strippedText) : startsRefusal);
|
||||
// fullText 已被剥离 thinking 标签
|
||||
const isShort = fullText.trim().length < 500;
|
||||
const startsRefusal = isRefusal(fullText.substring(0, 300));
|
||||
const isRealRefusal = stopReason !== 'max_tokens' && (isShort ? isRefusal(fullText) : startsRefusal);
|
||||
if (isRealRefusal) {
|
||||
log.info('Handler', 'sanitize', `非流式抑制纯文本拒绝响应`, { preview: fullText.substring(0, 200) });
|
||||
textToSend = 'Let me proceed with the task.';
|
||||
|
||||
Reference in New Issue
Block a user