From 48de59cba7735cee209a054c357736d9c6fafc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=B5=B7?= <7836246@qq.com> Date: Mon, 16 Mar 2026 09:44:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v2.7.0=20=E2=80=94=20API=E9=89=B4?= =?UTF-8?q?=E6=9D=83=20+=20Thinking=E6=94=AF=E6=8C=81=20+=20=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E9=A2=84=E7=AE=97=20+=20response=5Fformat=20+=20Visio?= =?UTF-8?q?n=E7=8B=AC=E7=AB=8B=E4=BB=A3=E7=90=86=20+=20=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 新功能: - 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 拒绝措辞匹配模式 --- CHANGELOG.md | 59 +++++++++++++++++++++++ config.yaml | 23 +++++++-- package.json | 2 +- src/config.ts | 12 ++++- src/converter.ts | 77 +++++++++++++++++++++++++----- src/handler.ts | 74 ++++++++++++++++++++++++++--- src/index.ts | 25 ++++++++++ src/openai-handler.ts | 107 +++++++++++++++++++++++++++++++++++++++++- src/openai-types.ts | 4 ++ src/proxy-agent.ts | 23 ++++++++- src/types.ts | 3 ++ src/vision.ts | 4 +- 12 files changed, 385 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e859e86..e64ce9b 100644 --- a/CHANGELOG.md +++ b/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` 参数即启用 +- 系统提示词注入 `` 引导,模型输出自动提取 +- 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) ### 🗜️ 渐进式历史压缩 diff --git a/config.yaml b/config.yaml index bea76ef..261aaa1 100644 --- a/config.yaml +++ b/config.yaml @@ -6,10 +6,21 @@ port: 3010 # 请求超时(秒) timeout: 120 -# 代理设置(可选) +# ==================== API 鉴权(推荐公网部署时开启) ==================== +# 配置后所有 POST 请求必须携带 Bearer token 才能访问 +# 客户端使用方式:Authorization: Bearer 或 x-api-key: +# 支持多个 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" diff --git a/package.json b/package.json index 0166520..4c7b16e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/config.ts b/src/config.ts index 9388f6a..76d0b43 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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) { diff --git a/src/converter.ts b/src/converter.ts index baaf072..bca1dec 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -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 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 标签 + if (req.thinking?.type === 'enabled') { + const thinkingHint = '\n\nBefore responding, think through the problem step by step inside ... 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 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) { diff --git a/src/handler.ts b/src/handler.ts index 08953c9..95e255a 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -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 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('')) { + const thinkingMatch = fullResponse.match(/([\s\S]*?)<\/thinking>/g); + if (thinkingMatch) { + thinkingContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n'); + fullResponse = fullResponse.replace(/[\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('')) { + const thinkingMatch = fullText.match(/([\s\S]*?)<\/thinking>/g); + if (thinkingMatch) { + thinkingContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n'); + fullText = fullText.replace(/[\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') { diff --git a/src/index.ts b/src/index.ts index 9f29968..9330b8f 100644 --- a/src/index.ts +++ b/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 ', 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 ║'); diff --git a/src/openai-handler.ts b/src/openai-handler.ts index ea8e132..aedd2fa 100644 --- a/src/openai-handler.ts +++ b/src/openai-handler.ts @@ -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) => { // 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).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('')) { + const thinkingMatch = fullResponse.match(/([\s\S]*?)<\/thinking>/g); + if (thinkingMatch) { + if (thinkingEnabled) { + reasoningContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n'); + } + fullResponse = fullResponse.replace(/[\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, + 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('')) { + const thinkingMatch = fullText.match(/([\s\S]*?)<\/thinking>/g); + if (thinkingMatch) { + if (thinkingEnabled) { + reasoningContent = thinkingMatch.map(m => m.replace(/<\/?thinking>/g, '').trim()).join('\n\n'); + } + const stripped = fullText.replace(/[\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('')) { + fullText = fullText.replace(/[\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 : {}), }, 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') { diff --git a/src/openai-types.ts b/src/openai-types.ts index 237e53d..f6f005b 100644 --- a/src/openai-types.ts +++ b/src/openai-types.ts @@ -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 }; + }; } export interface OpenAIMessage { diff --git a/src/proxy-agent.ts b/src/proxy-agent.ts index 6f97116..54901ab 100644 --- a/src/proxy-agent.ts +++ b/src/proxy-agent.ts @@ -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 { const dispatcher = getProxyDispatcher(); return dispatcher ? { dispatcher } : {}; } + +/** + * ★ Vision 独立代理:优先使用 vision.proxy,否则回退到全局 proxy + * Cursor API 国内可直连不需要代理,但图片分析 API 可能需要 + */ +export function getVisionProxyFetchOptions(): Record { + 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(); +} diff --git a/src/types.ts b/src/types.ts index 978b4ba..ddbfcf5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/src/vision.ts b/src/vision.ts index 2303842..28b80fe 100644 --- a/src/vision.ts +++ b/src/vision.ts @@ -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 { @@ -123,7 +123,7 @@ async function callVisionAPI(imageBlocks: AnthropicContentBlock[]): Promise