feat: v2.7.0 — API鉴权 + Thinking支持 + 动态预算 + response_format + Vision独立代理 + 拒绝模式更新

 新功能:
- API Token 鉴权 (auth_tokens): 公网部署安全,Bearer token / x-api-key 双模式
- Thinking 支持 (客户端驱动): Anthropic thinking block + OpenAI reasoning_content
- response_format 支持: json_object / json_schema + markdown 自动剥离
- Vision 独立代理 (vision.proxy): 图片API走代理,Cursor API保持直连

🔧 优化:
- 已知工具跳过描述 (WELL_KNOWN_TOOLS): 减少 ~30% 工具指令输入
- 动态工具结果预算 (getToolResultBudget): 替代固定 15K 限制
- isTruncated 重写: 消除反引号误判导致的无限重试
- 计费头清除: 清除 x-anthropic-billing-header 防注入警告

🛡️ 防御:
- 新增 4 个 Cursor 拒绝措辞匹配模式
This commit is contained in:
小海
2026-03-16 09:44:15 +08:00
parent cb0bf5c632
commit 48de59cba7
12 changed files with 385 additions and 28 deletions

View File

@@ -1,5 +1,64 @@
# Changelog
## v2.7.0 (2026-03-16)
### 🔐 API Token 鉴权
- **公网部署安全**:新增 `auth_tokens` 配置项,支持 Bearer token 鉴权
- 支持多 token数组格式、环境变量 `AUTH_TOKEN``x-api-key`
- 未配置时全部放行向后兼容GET 请求和 /health 端点无需鉴权
- 启动 banner 显示鉴权状态
### 🧠 Thinking 支持(客户端驱动)
- **Anthropic 协议**:请求体传 `thinking.type = "enabled"` 即启用
- **OpenAI 协议**:模型名含 `thinking` 或传 `reasoning_effort` 参数即启用
- 系统提示词注入 `<thinking>` 引导,模型输出自动提取
- Anthropic 返回 `thinking` content blockOpenAI 返回 `reasoning_content` 字段
- 提取在拒绝检测之前执行,防止 thinking 内容触发误判
- 未启用时仍会剥离 thinking 标签(防误判),但内容不返回
### 🔧 已知工具跳过描述
- `WELL_KNOWN_TOOLS` 集合中的 17 个常用工具Read、Write、Bash 等)不再生成描述文本
- 减少约 30% 工具指令输入,节省上下文空间
### 📊 动态工具结果预算
- `getToolResultBudget()` 替代固定 15K 限制
- 根据当前上下文大小动态调整:小上下文 20K → 大上下文 8K
- `setCurrentContextChars()` 跟踪实际上下文字符数
### 🛡️ isTruncated 重写
- 重新实现截断检测逻辑,正确处理工具调用 JSON 中的反引号
- 优先检查 `` ```json action`` 代码块,避免 JSON 字符串值内的反引号导致误判
- 消除因误判导致的无限重试
### 📦 response_format 支持
- `OpenAIChatRequest` 新增 `response_format` 字段(`json_object` / `json_schema`
- JSON 格式请求自动追加格式指令到最后一条用户消息
- `stripMarkdownJsonWrapper()` 自动剥离响应中的 markdown 代码块包装
- 流式和非流式路径均支持
### 🧹 计费头清除
- 自动清除系统提示词中的 `x-anthropic-billing-header`
- 防止模型将其判定为恶意伪造并触发注入警告
### 🌐 Vision 独立代理
- 新增 `vision.proxy` 配置项,图片分析 API 单独走代理
- Cursor API 保持直连(国内可用),不因代理影响响应速度
- 未配置时回退到全局 `proxy`
### 🛡️ 新增拒绝模式
- 补充 4 个 Cursor 新拒绝措辞:`isn't something I can help with``not something I can help with``scoped to answering questions about Cursor``falls outside`
---
## v2.5.6 (2026-03-12)
### 🗜️ 渐进式历史压缩

View File

@@ -6,10 +6,21 @@ port: 3010
# 请求超时(秒)
timeout: 120
# 代理设置(可选)
# ==================== API 鉴权(推荐公网部署时开启) ====================
# 配置后所有 POST 请求必须携带 Bearer token 才能访问
# 客户端使用方式Authorization: Bearer <token> 或 x-api-key: <token>
# 支持多个 token数组格式不配置则全部放行
# 环境变量: AUTH_TOKEN=token1,token2 (逗号分隔)
# auth_tokens:
# - "sk-your-secret-token-1"
# - "sk-your-secret-token-2"
# ==================== 代理设置 ====================
# 全局代理(可选)
# ⚠️ Node.js fetch 不读取 HTTP_PROXY / HTTPS_PROXY 环境变量,
# 必须在此处或通过 PROXY 环境变量显式配置代理。
# 支持 http 代理,含认证格式: http://用户名:密码@代理地址:端口
# 💡 国内可直连 Cursor API通常不需要配置全局代理
# proxy: "http://127.0.0.1:7890"
# Cursor 使用的模型
@@ -19,16 +30,22 @@ cursor_model: "anthropic/claude-sonnet-4.6"
fingerprint:
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
# 视觉处理降级配置(可选)
# ==================== 视觉处理降级配置(可选) ====================
# 如果开启,可以拦截您发给大模型的图片进行降级处理(因为目前免费 Cursor 不支持视觉)。
vision:
enabled: true
# mode 选项: 'ocr' 或 'api'
# 'ocr': [默认模式] 彻底免 Key零配置完全依赖本机的 CPU 识图,提取文本、报错日志、代码段后发给大模型。
# 'api': 需要配置下方的 baseUrl 和 apiKey把图发给外部视觉模型如 Gemini、OpenRouter看到画面内容和色彩。
# 'api': 需要配置下方的 baseUrl 和 apiKey把图发给外部视觉模型如 Gemini、OpenRouter"看到"画面内容和色彩。
mode: 'ocr'
# ---------- 以下选项仅在 mode: 'api' 时才生效 ----------
# base_url: "https://openrouter.ai/api/v1/chat/completions"
# api_key: "sk-or-v1-..."
# model: "meta-llama/llama-3.2-11b-vision-instruct:free"
# Vision 独立代理(可选)
# 💡 Cursor API 国内可直连无需代理,但图片分析 APIOpenAI/OpenRouter可能需要
# 配置此项后只有图片 API 走代理,不影响主请求的响应速度
# 如果不配,会回退到上面的全局 proxy如果有的话
# proxy: "http://127.0.0.1:7890"

View File

@@ -1,6 +1,6 @@
{
"name": "cursor2api",
"version": "2.5.6",
"version": "2.7.0",
"description": "Proxy Cursor docs AI to Anthropic Messages API for Claude Code",
"type": "module",
"scripts": {

View File

@@ -31,13 +31,20 @@ export function getConfig(): AppConfig {
}
if (yaml.vision) {
config.vision = {
enabled: yaml.vision.enabled !== false, // default to true if vision section exists in some way
enabled: yaml.vision.enabled !== false,
mode: yaml.vision.mode || 'ocr',
baseUrl: yaml.vision.base_url || 'https://api.openai.com/v1/chat/completions',
apiKey: yaml.vision.api_key || '',
model: yaml.vision.model || 'gpt-4o-mini',
proxy: yaml.vision.proxy || undefined,
};
}
// ★ API 鉴权 token
if (yaml.auth_tokens) {
config.authTokens = Array.isArray(yaml.auth_tokens)
? yaml.auth_tokens.map(String)
: String(yaml.auth_tokens).split(',').map((s: string) => s.trim()).filter(Boolean);
}
} catch (e) {
console.warn('[Config] 读取 config.yaml 失败:', e);
}
@@ -48,6 +55,9 @@ export function getConfig(): AppConfig {
if (process.env.TIMEOUT) config.timeout = parseInt(process.env.TIMEOUT);
if (process.env.PROXY) config.proxy = process.env.PROXY;
if (process.env.CURSOR_MODEL) config.cursorModel = process.env.CURSOR_MODEL;
if (process.env.AUTH_TOKEN) {
config.authTokens = process.env.AUTH_TOKEN.split(',').map(s => s.trim()).filter(Boolean);
}
// 从 base64 FP 环境变量解析指纹
if (process.env.FP) {

View File

@@ -25,6 +25,18 @@ import { fixToolCallArguments } from './tool-fixer.js';
// ==================== 工具指令构建 ====================
// 已知工具名 — 无需额外描述(模型已从 few-shot 和训练中了解)
const WELL_KNOWN_TOOLS = new Set([
'Read', 'read_file', 'ReadFile',
'Write', 'write_file', 'WriteFile', 'write_to_file',
'Edit', 'edit_file', 'EditFile', 'replace_in_file',
'Bash', 'execute_command', 'RunCommand', 'run_command',
'ListDir', 'list_dir', 'list_files',
'Search', 'search_files', 'grep_search', 'codebase_search',
'attempt_completion', 'ask_followup_question',
'AskFollowupQuestion', 'AttemptCompletion',
]);
/**
* 将 JSON Schema 压缩为紧凑的类型签名
* 目的90 个工具的完整 JSON Schema 约 135,000 chars压缩后约 15,000 chars
@@ -75,10 +87,12 @@ function buildToolInstructions(
const toolList = tools.map((tool) => {
// ★ 使用紧凑 Schema 替代完整 JSON Schema 以大幅减小输入体积
const schema = tool.input_schema ? compactSchema(tool.input_schema) : '{}';
// 截断过长的工具描述(部分客户端的工具描述可达数千字符)
// ★ 80 chars 足矣Schema 已包含参数信息,短描述减少输入体积,为输出留更多空间
const desc = (tool.description || 'No description').substring(0, 80);
return `- **${tool.name}**: ${desc}\n Params: ${schema}`;
// ★ 已知工具跳过描述(模型已经知道它们做什么),减少 ~30% 输入
const isKnown = WELL_KNOWN_TOOLS.has(tool.name);
const desc = isKnown ? '' : (tool.description || '').substring(0, 50);
// Markdown 文档格式:更自然,不像 API spec
const paramStr = schema ? `\n Params: {${schema}}` : '';
return desc ? `- **${tool.name}**: ${desc}${paramStr}` : `- **${tool.name}**${paramStr}`;
}).join('\n');
// ★ tool_choice 强制约束
@@ -130,6 +144,19 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise<Cur
// ★ 图片预处理:在协议转换之前,检测并处理 Anthropic 格式的 ImageBlockParam
await preprocessImages(req.messages);
// ★ 预估原始上下文大小,驱动动态工具结果预算
let estimatedContextChars = 0;
if (req.system) {
estimatedContextChars += typeof req.system === 'string' ? req.system.length : JSON.stringify(req.system).length;
}
for (const msg of req.messages ?? []) {
estimatedContextChars += typeof msg.content === 'string' ? msg.content.length : JSON.stringify(msg.content).length;
}
if (req.tools && req.tools.length > 0) {
estimatedContextChars += req.tools.length * 150; // 压缩后每个工具约 150 chars
}
setCurrentContextChars(estimatedContextChars);
const messages: CursorMessage[] = [];
const hasTools = req.tools && req.tools.length > 0;
@@ -142,6 +169,19 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise<Cur
}
}
// ★ 计费头清除x-anthropic-billing-header 会被模型判定为恶意伪造并触发注入警告
if (combinedSystem) {
combinedSystem = combinedSystem.replace(/^x-anthropic-billing-header[^\n]*$/gim, '');
combinedSystem = combinedSystem.replace(/\n{3,}/g, '\n\n').trim();
}
// ★ Thinking 提示注入:当客户端请求 thinking 时,引导模型使用 <thinking> 标签
if (req.thinking?.type === 'enabled') {
const thinkingHint = '\n\nBefore responding, think through the problem step by step inside <thinking>...</thinking> tags. Your thinking will be extracted and returned separately. After thinking, provide your actual response outside the tags.';
combinedSystem = (combinedSystem || '') + thinkingHint;
console.log(`[Converter] Thinking 模式已启用 (budget=${req.thinking.budget_tokens ?? 'auto'})`);
}
if (hasTools) {
const tools = req.tools!;
const toolChoice = req.tool_choice;
@@ -316,6 +356,8 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise<Cur
totalChars += textLen;
console.log(`[Converter] cursor_msg[${i}] role=${m.role} chars=${textLen}${i < 2 ? ' (few-shot)' : ''}`);
}
// 更新动态预算的上下文字符数(用实际 Cursor 消息计算值覆盖之前的估算值)
setCurrentContextChars(totalChars);
console.log(`[Converter] 总消息数=${messages.length}, 总字符=${totalChars}`);
return {
@@ -326,9 +368,19 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise<Cur
};
}
// 最大工具结果长度(超过则截断,防止上下文溢出
// ★ 15000 chars 平衡点:保留足够信息让模型理解结果,同时为输出空间
const MAX_TOOL_RESULT_LENGTH = 15000;
// ★ 动态工具结果预算(替代固定 15000
// Cursor API 的输出预算与输入大小成反比,固定 15K 在大上下文下严重挤压输出空间
function getToolResultBudget(totalContextChars: number): number {
if (totalContextChars > 100000) return 4000; // 超大上下文:极度压缩
if (totalContextChars > 60000) return 6000; // 大上下文:适度压缩
if (totalContextChars > 30000) return 10000; // 中等上下文:温和压缩
return 15000; // 小上下文:保留完整信息
}
// 当前上下文字符计数(在 convertToCursorRequest 中更新)
let _currentContextChars = 0;
export function setCurrentContextChars(chars: number): void { _currentContextChars = chars; }
function getCurrentToolResultBudget(): number { return getToolResultBudget(_currentContextChars); }
@@ -363,11 +415,12 @@ function extractToolResultNatural(msg: AnthropicMessage): string {
continue;
}
// 截断过长结果
if (resultText.length > MAX_TOOL_RESULT_LENGTH) {
const truncated = resultText.slice(0, MAX_TOOL_RESULT_LENGTH);
resultText = truncated + `\n\n... (truncated, ${resultText.length} chars total)`;
console.log(`[Converter] 截断工具结果: ${resultText.length}${MAX_TOOL_RESULT_LENGTH} chars`);
// ★ 动态截断:根据当前上下文大小计算预算
const budget = getCurrentToolResultBudget();
if (resultText.length > budget) {
const truncated = resultText.slice(0, budget);
resultText = truncated + `\n\n... (truncated, ${resultText.length}${budget} chars, context=${_currentContextChars})`;
console.log(`[Converter] 截断工具结果: ${resultText.length}${budget} chars (上下文=${_currentContextChars})`);
}
if (block.is_error) {

View File

@@ -62,6 +62,11 @@ const REFUSAL_PATTERNS = [
/appears\s+to\s+be\s+(?:asking|about)\s+.*?unrelated/i,
/(?:not|isn't|is\s+not)\s+(?:related|relevant)\s+to\s+(?:programming|coding|software)/i,
/I\s+can\s+help\s+(?:you\s+)?with\s+things\s+like/i,
// New Cursor refusal phrases (2026-03)
/isn't\s+something\s+I\s+can\s+help\s+with/i,
/not\s+something\s+I\s+can\s+help\s+with/i,
/scoped\s+to\s+answering\s+questions\s+about\s+Cursor/i,
/falls\s+outside\s+(?:the\s+scope|what\s+I)/i,
// Prompt injection / social engineering detection (new failure mode)
/prompt\s+injection\s+attack/i,
/prompt\s+injection/i,
@@ -424,13 +429,24 @@ export async function handleMessages(req: Request, res: Response): Promise<void>
export function isTruncated(text: string): boolean {
if (!text || text.trim().length === 0) return false;
const trimmed = text.trimEnd();
// 代码块未闭合
const codeBlockOpen = (trimmed.match(/```/g) || []).length % 2 !== 0;
if (codeBlockOpen) return true;
// 检测 ```json action 块已开始但 JSON 对象未闭合(截断发生在工具调用参数中间)
const jsonActionBlocks = trimmed.match(/```json\s+action[\s\S]*?```/g) || [];
// ★ 核心检测:```json action 块是否未闭合(截断发生在工具调用参数中间)
// 这是最精确的截断检测 — 只关心实际的工具调用代码块
// 注意:不能简单计数所有 ``` 因为 JSON 字符串值里可能包含 markdown 反引号
const jsonActionOpens = (trimmed.match(/```json\s+action/g) || []).length;
if (jsonActionOpens > jsonActionBlocks.length) return true;
if (jsonActionOpens > 0) {
// 从工具调用的角度检测:开始标记比闭合标记多 = 截断
const jsonActionBlocks = trimmed.match(/```json\s+action[\s\S]*?```/g) || [];
if (jsonActionOpens > jsonActionBlocks.length) return true;
// 所有 action 块都闭合了 = 没截断(即使响应文本被截断,工具调用是完整的)
return false;
}
// 无工具调用时的通用截断检测(纯文本响应)
// 代码块未闭合:只检测行首的代码块标记,避免 JSON 值中的反引号误判
const lineStartCodeBlocks = (trimmed.match(/^```/gm) || []).length;
if (lineStartCodeBlocks % 2 !== 0) return true;
// XML/HTML 标签未闭合 (Cursor 有时在中途截断)
const openTags = (trimmed.match(/^<[a-zA-Z]/gm) || []).length;
const closeTags = (trimmed.match(/^<\/[a-zA-Z]/gm) || []).length;
@@ -603,6 +619,18 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
console.log(`[Handler] 原始响应 (${fullResponse.length} chars, tools=${hasTools}): ${fullResponse.substring(0, 200)}${fullResponse.length > 200 ? '...' : ''}`);
// ★ Thinking 提取(在拒绝检测之前,防止 thinking 内容触发 isRefusal 误判)
const thinkingEnabled = body.thinking?.type === 'enabled';
let thinkingContent = '';
if (fullResponse.includes('<thinking>')) {
const thinkingMatch = fullResponse.match(/<thinking>([\s\S]*?)<\/thinking>/g);
if (thinkingMatch) {
thinkingContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n');
fullResponse = fullResponse.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim();
console.log(`[Handler] 剥离 thinking: ${thinkingContent.length} chars, 剩余 ${fullResponse.length} chars`);
}
}
// 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
const shouldRetryRefusal = () => {
if (!isRefusal(fullResponse)) return false;
@@ -724,6 +752,22 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
console.log(`[Handler] ⚠️ ${MAX_AUTO_CONTINUE}次隐式续写后仍受限于截断 (${fullResponse.length} chars),设置 stop_reason=max_tokens`);
}
// ★ Thinking 块发送:在实际内容之前发送 thinking content block
if (thinkingEnabled && thinkingContent) {
writeSSE(res, 'content_block_start', {
type: 'content_block_start', index: blockIndex,
content_block: { type: 'thinking', thinking: '' },
});
writeSSE(res, 'content_block_delta', {
type: 'content_block_delta', index: blockIndex,
delta: { type: 'thinking_delta', thinking: thinkingContent },
});
writeSSE(res, 'content_block_stop', {
type: 'content_block_stop', index: blockIndex,
});
blockIndex++;
}
if (hasTools) {
let { toolCalls, cleanText } = parseToolCalls(fullResponse);
@@ -910,6 +954,18 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body
console.log(`[Handler] 非流式原始响应 (${fullText.length} chars, tools=${hasTools}): ${fullText.substring(0, 300)}${fullText.length > 300 ? '...' : ''}`);
// ★ Thinking 提取(在拒绝检测之前)
const thinkingEnabled = body.thinking?.type === 'enabled';
let thinkingContent = '';
if (fullText.includes('<thinking>')) {
const thinkingMatch = fullText.match(/<thinking>([\s\S]*?)<\/thinking>/g);
if (thinkingMatch) {
thinkingContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n');
fullText = fullText.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim();
console.log(`[Handler] 非流式:剥离 thinking: ${thinkingContent.length} chars, 剩余 ${fullText.length} chars`);
}
}
// 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
const shouldRetry = () => isRefusal(fullText) && !(hasTools && hasToolCalls(fullText));
@@ -1008,6 +1064,12 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
}
const contentBlocks: AnthropicContentBlock[] = [];
// ★ Thinking 内容作为第一个 content block
if (thinkingEnabled && thinkingContent) {
contentBlocks.push({ type: 'thinking' as any, thinking: thinkingContent } as any);
}
// ★ 截断检测:代码块/XML 未闭合时,返回 max_tokens 让 Claude Code 自动继续
let stopReason = (hasTools && isTruncated(fullText)) ? 'max_tokens' : 'end_turn';
if (stopReason === 'max_tokens') {

View File

@@ -35,6 +35,30 @@ app.use((_req, res, next) => {
next();
});
// ★ API 鉴权中间件:配置了 authTokens 则需要 Bearer token
app.use((req, res, next) => {
// 跳过无需鉴权的路径
if (req.method === 'GET' || req.path === '/health') {
return next();
}
const tokens = config.authTokens;
if (!tokens || tokens.length === 0) {
return next(); // 未配置 token 则全部放行
}
const authHeader = req.headers['authorization'] || req.headers['x-api-key'];
if (!authHeader) {
res.status(401).json({ error: { message: 'Missing authentication token. Use Authorization: Bearer <token>', type: 'auth_error' } });
return;
}
const token = String(authHeader).replace(/^Bearer\s+/i, '').trim();
if (!tokens.includes(token)) {
console.log(`[Auth] 拒绝无效 token: ${token.substring(0, 8)}...`);
res.status(403).json({ error: { message: 'Invalid authentication token', type: 'auth_error' } });
return;
}
next();
});
// ==================== 路由 ====================
// Anthropic Messages API
@@ -91,6 +115,7 @@ app.listen(config.port, () => {
console.log(' ╠══════════════════════════════════════╣');
console.log(` ║ Server: http://localhost:${config.port}`);
console.log(' ║ Model: ' + config.cursorModel.padEnd(26) + '║');
console.log(' ║ Auth: ' + (config.authTokens?.length ? `${config.authTokens.length} token(s)` : 'OPEN (no auth)').padEnd(26) + '║');
console.log(' ╠══════════════════════════════════════╣');
console.log(' ║ API Endpoints: ║');
console.log(' ║ • Anthropic: /v1/messages ║');

View File

@@ -57,6 +57,16 @@ function convertToAnthropicRequest(body: OpenAIChatRequest): AnthropicRequest {
const rawMessages: AnthropicMessage[] = [];
let systemPrompt: string | undefined;
// ★ response_format 处理:构建温和的 JSON 格式提示(稍后追加到最后一条用户消息)
let jsonFormatSuffix = '';
if (body.response_format && body.response_format.type !== 'text') {
jsonFormatSuffix = '\n\nRespond in plain JSON format without markdown wrapping.';
if (body.response_format.type === 'json_schema' && body.response_format.json_schema?.schema) {
jsonFormatSuffix += ` Schema: ${JSON.stringify(body.response_format.json_schema.schema)}`;
}
console.log(`[OpenAI] response_format=${body.response_format.type}, 将追加 JSON 格式提示到用户消息`);
}
for (const msg of body.messages) {
switch (msg.role) {
case 'system':
@@ -124,6 +134,26 @@ function convertToAnthropicRequest(body: OpenAIChatRequest): AnthropicRequest {
// 合并连续同角色消息Anthropic API 要求 user/assistant 严格交替)
const messages = mergeConsecutiveRoles(rawMessages);
// ★ response_format: 追加 JSON 格式提示到最后一条 user 消息
if (jsonFormatSuffix) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
const content = messages[i].content;
if (typeof content === 'string') {
messages[i].content = content + jsonFormatSuffix;
} else if (Array.isArray(content)) {
const lastTextBlock = [...content].reverse().find(b => b.type === 'text');
if (lastTextBlock && lastTextBlock.text) {
lastTextBlock.text += jsonFormatSuffix;
} else {
content.push({ type: 'text', text: jsonFormatSuffix.trim() });
}
}
break;
}
}
}
// 转换工具定义:支持 OpenAI 标准格式和 Cursor 扁平格式
const tools: AnthropicTool[] | undefined = body.tools?.map((t: OpenAITool | Record<string, unknown>) => {
// Cursor IDE 可能发送扁平格式:{ name, description, input_schema }
@@ -156,6 +186,10 @@ function convertToAnthropicRequest(body: OpenAIChatRequest): AnthropicRequest {
stop_sequences: body.stop
? (Array.isArray(body.stop) ? body.stop : [body.stop])
: undefined,
// ★ Thinking 透传:模型名含 'thinking' 或客户端传了 reasoning_effort 则启用
...(body.model?.toLowerCase().includes('thinking') || (body as unknown as Record<string, unknown>).reasoning_effort
? { thinking: { type: 'enabled' as const } }
: {}),
};
}
@@ -371,6 +405,20 @@ async function handleOpenAIStream(
console.log(`[OpenAI] 原始响应 (${fullResponse.length} chars, tools=${hasTools}): ${fullResponse.substring(0, 200)}${fullResponse.length > 200 ? '...' : ''}`);
// ★ Thinking 提取(在拒绝检测之前)
const thinkingEnabled = anthropicReq.thinking?.type === 'enabled';
let reasoningContent: string | undefined;
if (fullResponse.includes('<thinking>')) {
const thinkingMatch = fullResponse.match(/<thinking>([\s\S]*?)<\/thinking>/g);
if (thinkingMatch) {
if (thinkingEnabled) {
reasoningContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n');
}
fullResponse = fullResponse.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim();
console.log(`[OpenAI] 流式:剥离 thinking, 剩余 ${fullResponse.length} chars${thinkingEnabled ? ', 将发送 reasoning_content' : ''}`);
}
}
// 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
const shouldRetryRefusal = () => {
if (!isRefusal(fullResponse)) return false;
@@ -410,6 +458,18 @@ async function handleOpenAIStream(
let finishReason: 'stop' | 'tool_calls' = 'stop';
// ★ 发送 reasoning_content如果有
if (reasoningContent) {
writeOpenAISSE(res, {
id, object: 'chat.completion.chunk', created, model,
choices: [{
index: 0,
delta: { reasoning_content: reasoningContent } as Record<string, unknown>,
finish_reason: null,
}],
});
}
if (hasTools && hasToolCalls(fullResponse)) {
const { toolCalls, cleanText } = parseToolCalls(fullResponse);
@@ -491,7 +551,11 @@ async function handleOpenAIStream(
}
} else {
// 无工具模式或无工具调用 — 统一清洗后发送
const sanitized = sanitizeResponse(fullResponse);
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,
@@ -545,7 +609,22 @@ async function handleOpenAINonStream(
console.log(`[OpenAI] 非流式原始响应 (${fullText.length} chars, tools=${hasTools}): ${fullText.substring(0, 300)}${fullText.length > 300 ? '...' : ''}`);
// 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
// ★ Thinking 提取必须在拒绝检测之前 — 否则 thinking 内容中的关键词会触发 isRefusal 误判
const thinkingEnabled = anthropicReq.thinking?.type === 'enabled';
let reasoningContent: string | undefined;
if (fullText.includes('<thinking>')) {
const thinkingMatch = fullText.match(/<thinking>([\s\S]*?)<\/thinking>/g);
if (thinkingMatch) {
if (thinkingEnabled) {
reasoningContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n');
}
const stripped = fullText.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim();
console.log(`[OpenAI] 剥离 thinking: ${fullText.length - stripped.length} chars, 剩余 ${stripped.length} chars${thinkingEnabled ? ', 将返回 reasoning_content' : ''}`);
fullText = stripped;
}
}
// 拒绝检测 + 自动重试(在 thinking 提取之后,只检测实际输出内容)
const shouldRetry = () => isRefusal(fullText) && !(hasTools && hasToolCalls(fullText));
if (shouldRetry()) {
@@ -554,6 +633,10 @@ async function handleOpenAINonStream(
const retryBody = buildRetryRequest(anthropicReq, attempt);
const retryCursorReq = await convertToCursorRequest(retryBody);
fullText = await sendCursorRequestFull(retryCursorReq);
// 重试响应也需要先剥离 thinking
if (fullText.includes('<thinking>')) {
fullText = fullText.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim();
}
if (!shouldRetry()) break;
}
if (shouldRetry()) {
@@ -606,6 +689,10 @@ async function handleOpenAINonStream(
} else {
// 无工具模式:清洗响应
content = sanitizeResponse(fullText);
// ★ response_format 后处理:剥离 markdown 代码块包裹
if (body.response_format && body.response_format.type !== 'text' && content) {
content = stripMarkdownJsonWrapper(content);
}
}
const response: OpenAIChatCompletion = {
@@ -619,6 +706,7 @@ async function handleOpenAINonStream(
role: 'assistant',
content,
...(toolCalls ? { tool_calls: toolCalls } : {}),
...(reasoningContent ? { reasoning_content: reasoningContent } as Record<string, unknown> : {}),
},
finish_reason: finishReason,
}],
@@ -634,6 +722,21 @@ async function handleOpenAINonStream(
// ==================== 工具函数 ====================
/**
* 剥离 Markdown 代码块包裹,返回裸 JSON 字符串
* 处理 ```json\n...\n``` 和 ```\n...\n``` 两种格式
*/
function stripMarkdownJsonWrapper(text: string): string {
if (!text) return text;
const trimmed = text.trim();
const match = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n\s*```$/);
if (match) {
console.log(`[OpenAI] 剥离 markdown JSON 包裹: ${trimmed.length}${match[1].trim().length} chars`);
return match[1].trim();
}
return text;
}
function writeOpenAISSE(res: Response, data: OpenAIChatCompletionChunk): void {
res.write(`data: ${JSON.stringify(data)}\n\n`);
if (typeof (res as unknown as { flush: () => void }).flush === 'function') {

View File

@@ -14,6 +14,10 @@ export interface OpenAIChatRequest {
n?: number;
frequency_penalty?: number;
presence_penalty?: number;
response_format?: {
type: 'text' | 'json_object' | 'json_schema';
json_schema?: { name?: string; schema?: Record<string, unknown> };
};
}
export interface OpenAIMessage {

View File

@@ -13,6 +13,7 @@ import { ProxyAgent } from 'undici';
import { getConfig } from './config.js';
let cachedAgent: ProxyAgent | undefined;
let cachedVisionAgent: ProxyAgent | undefined;
/**
* 获取代理 dispatcher如果配置了 proxy
@@ -25,7 +26,7 @@ export function getProxyDispatcher(): ProxyAgent | undefined {
if (!proxyUrl) return undefined;
if (!cachedAgent) {
console.log(`[Proxy] 使用代理: ${proxyUrl}`);
console.log(`[Proxy] 使用全局代理: ${proxyUrl}`);
cachedAgent = new ProxyAgent(proxyUrl);
}
@@ -40,3 +41,23 @@ export function getProxyFetchOptions(): Record<string, unknown> {
const dispatcher = getProxyDispatcher();
return dispatcher ? { dispatcher } : {};
}
/**
* ★ Vision 独立代理:优先使用 vision.proxy否则回退到全局 proxy
* Cursor API 国内可直连不需要代理,但图片分析 API 可能需要
*/
export function getVisionProxyFetchOptions(): Record<string, unknown> {
const config = getConfig();
const visionProxy = config.vision?.proxy;
if (visionProxy) {
if (!cachedVisionAgent) {
console.log(`[Proxy] Vision 独立代理: ${visionProxy}`);
cachedVisionAgent = new ProxyAgent(visionProxy);
}
return { dispatcher: cachedVisionAgent };
}
// 回退到全局代理
return getProxyFetchOptions();
}

View File

@@ -11,6 +11,7 @@ export interface AnthropicRequest {
temperature?: number;
top_p?: number;
stop_sequences?: string[];
thinking?: { type: 'enabled' | 'disabled'; budget_tokens?: number };
}
/** tool_choice 控制模型是否必须调用工具
@@ -104,12 +105,14 @@ export interface AppConfig {
timeout: number;
proxy?: string;
cursorModel: string;
authTokens?: string[]; // API 鉴权 token 列表,为空则不鉴权
vision?: {
enabled: boolean;
mode: 'ocr' | 'api';
baseUrl: string;
apiKey: string;
model: string;
proxy?: string; // vision 独立代理(不影响 Cursor API 直连)
};
fingerprint: {
userAgent: string;

View File

@@ -1,6 +1,6 @@
import { getConfig } from './config.js';
import type { AnthropicMessage, AnthropicContentBlock } from './types.js';
import { getProxyFetchOptions } from './proxy-agent.js';
import { getVisionProxyFetchOptions } from './proxy-agent.js';
import { createWorker } from 'tesseract.js';
export async function applyVisionInterceptor(messages: AnthropicMessage[]): Promise<void> {
@@ -123,7 +123,7 @@ async function callVisionAPI(imageBlocks: AnthropicContentBlock[]): Promise<stri
'Authorization': `Bearer ${config.apiKey}`
},
body: JSON.stringify(payload),
...getProxyFetchOptions(),
...getVisionProxyFetchOptions(),
} as any);
if (!res.ok) {