mirror of
https://github.com/7836246/cursor2api.git
synced 2026-09-03 07:20:02 +08:00
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:
59
CHANGELOG.md
59
CHANGELOG.md
@@ -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 block,OpenAI 返回 `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)
|
||||
|
||||
### 🗜️ 渐进式历史压缩
|
||||
|
||||
23
config.yaml
23
config.yaml
@@ -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 国内可直连无需代理,但图片分析 API(OpenAI/OpenRouter)可能需要
|
||||
# 配置此项后只有图片 API 走代理,不影响主请求的响应速度
|
||||
# 如果不配,会回退到上面的全局 proxy(如果有的话)
|
||||
# proxy: "http://127.0.0.1:7890"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
25
src/index.ts
25
src/index.ts
@@ -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 ║');
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user