mirror of
https://github.com/7836246/cursor2api.git
synced 2026-09-03 07:20:02 +08:00
feat(v2.7.1): 智能压缩算法 + 可配置压缩系统 + 日志鉴权 + Thinking 修复
🗜️ 智能历史压缩算法: - 修复 JSON Action 块截断: 工具调用消息摘要化, 不再切断代码块 - 工具结果 60% 头 + 40% 尾保留, 错误信息不丢失 - 修复非工具模式 few-shot 偏移量 Bug - 普通文本在自然边界(换行符)处截断 ⚙️ 可配置压缩系统 (config.yaml): - compression.enabled: 开关 - compression.level: 1(轻度) / 2(中等) / 3(激进) - compression.keep_recent / early_msg_max_chars: 高级覆盖 - 支持 COMPRESSION_ENABLED / COMPRESSION_LEVEL 环境变量 🔐 日志查看器鉴权: - 配置 auth_tokens 后 /logs 及 API 端点需验证 - 精美登录页, token 缓存到 localStorage - 支持 query/header/x-api-key 三种传入方式 🧠 Thinking 修复: - 拒绝检测先剥离 <thinking> 标签, 防止误判 - OpenAI 格式默认启用 thinking
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__filesystem__read_text_file",
|
||||
"WebSearch"
|
||||
"WebSearch",
|
||||
"Bash(findstr:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## v2.7.1 (2026-03-16)
|
||||
|
||||
### 🗜️ 智能历史压缩算法
|
||||
|
||||
- **修复 JSON Action 块截断**:之前朴素的 `substring` 截断会切断 `` ```json action `` 代码块,产生未闭合标记和不完整 JSON,严重误导模型。现在对包含工具调用的 assistant 消息,提取工具名生成摘要(如 `[Executed: Write, Read]`),不再做子串截断
|
||||
- **工具结果头尾保留**:工具结果截断从"只保留头部"改为 **60% 头 + 40% 尾**,确保错误信息、stack trace 等末尾关键内容不丢失
|
||||
- **修复非工具模式偏移量**:few-shot 消息跳过偏移量从硬编码 `+2` 改为动态计算 `hasTools ? 2 : 0`,修复非工具模式下前2条消息无法参与压缩的问题
|
||||
- **自然边界截断**:普通文本在换行符处截断,避免切断单词或代码
|
||||
|
||||
### ⚙️ 可配置压缩系统
|
||||
|
||||
- 新增 `compression` 配置段(config.yaml),支持:
|
||||
- `enabled`:压缩开关(`true`/`false`),关闭后所有消息原样保留
|
||||
- `level`:压缩级别 1-3(轻度/中等/激进),每级预设不同的保留消息数和字符限制
|
||||
- `keep_recent`:高级选项,覆盖级别预设的保留消息数
|
||||
- `early_msg_max_chars`:高级选项,覆盖级别预设的早期消息字符上限
|
||||
- 支持环境变量 `COMPRESSION_ENABLED` / `COMPRESSION_LEVEL`,方便 Docker 部署
|
||||
|
||||
### 🔐 日志查看器鉴权
|
||||
|
||||
- 配置了 `auth_tokens` 后,访问 `/logs` 及所有 `/api/logs*` 端点需要验证身份
|
||||
- 精美的登录页面,输入 token 后通过 `/api/stats` 验证有效性
|
||||
- Token 存入 `localStorage`,刷新页面无需重新输入
|
||||
- 支持 query 参数 `?token=xxx`、`Authorization` header、`x-api-key` 三种传入方式
|
||||
- 页面右上角显示退出按钮,清除缓存并跳回登录页
|
||||
- 未配置 `auth_tokens` 时保持完全开放(向后兼容)
|
||||
|
||||
### 🧠 Thinking 拒绝误判修复
|
||||
|
||||
- **修复 thinking 触发拒绝检测**:模型的 `<thinking>` 内容中包含反思性语言(如 "haven't given a specific task"),被拒绝检测正则误判为拒绝响应
|
||||
- 拒绝检测现在先剥离 `<thinking>` 标签内容,仅对实际输出文本进行检测
|
||||
- 流式和非流式路径均已修复
|
||||
|
||||
### 🧠 OpenAI 格式 Thinking 默认启用
|
||||
|
||||
- OpenAI Chat Completions 协议不再依赖模型名包含 `thinking` 或传入 `reasoning_effort` 才启用
|
||||
- 所有 OpenAI 格式请求默认启用 thinking,确保 Claude Code 等客户端始终获得推理内容
|
||||
|
||||
---
|
||||
|
||||
## v2.7.0 (2026-03-16)
|
||||
|
||||
### 🔐 API Token 鉴权
|
||||
|
||||
12
README.md
12
README.md
@@ -1,8 +1,8 @@
|
||||
# Cursor2API v2.7
|
||||
# Cursor2API v2.7.1
|
||||
|
||||
将 Cursor 文档页免费 AI 对话接口代理转换为 **Anthropic Messages API** 和 **OpenAI Chat Completions API**,支持 **Claude Code** 和 **Cursor IDE** 使用。
|
||||
|
||||
> ⚠️ **版本说明**:当前 v2.7.0 是基于 v2.5.6 稳定版回滚后精选优化而来,v2.6.x 系列的完整代码可在 [Releases Tags](https://github.com/7836246/cursor2api/tags) 中获取。
|
||||
> ⚠️ **版本说明**:当前 v2.7.1 是基于 v2.7.0 优化而来,主要改进压缩算法和安全性。
|
||||
|
||||
## 原理
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
- **多层拒绝拦截** - 50+ 正则模式匹配拒绝文本(中英文),自动重试 + 认知重构绕过
|
||||
- **三层身份保护** - 身份探针拦截 + 拒绝重试 + 响应清洗,确保输出永远呈现 Claude 身份
|
||||
- **截断无缝续写** - Proxy 底层自动拼接被截断的工具响应(最多 6 次),含智能去重
|
||||
- **渐进式历史压缩** - 保留最近 6 条消息完整,仅截短早期超长文本
|
||||
- **渐进式历史压缩** - 智能识别消息类型,工具调用摘要化、工具结果头尾保留,不破坏 JSON 结构
|
||||
- **🆕 可配置压缩系统** - 支持开关 + 3档级别(轻度/中等/激进)+ 自定义参数,环境变量可覆盖
|
||||
- **🆕 日志查看器鉴权** - 配置 auth_tokens 后 /logs 页面需登录,token 缓存到 localStorage
|
||||
- **Schema 压缩** - 工具定义从完整 JSON Schema (~135k chars) 压缩为紧凑类型签名 (~15k chars)
|
||||
- **JSON 感知解析器** - 正确处理 JSON 中嵌入的代码块,五层容错解析
|
||||
- **Chrome TLS 指纹** - 模拟真实浏览器请求头
|
||||
@@ -58,6 +60,8 @@ npm install
|
||||
编辑 `config.yaml`:
|
||||
- `auth_tokens` - API 鉴权 token 列表(公网部署推荐配置,不配则全部放行)
|
||||
- `cursor_model` - 使用的模型(默认 `anthropic/claude-sonnet-4.6`)
|
||||
- `compression.enabled` - 压缩开关(默认开启)
|
||||
- `compression.level` - 压缩级别 1-3(1=轻度, 2=中等, 3=激进)
|
||||
- `proxy` - 全局代理(可选,国内通常不需要)
|
||||
- `vision.enabled` - 开启视觉拦截
|
||||
- `vision.mode` - 视觉模式:`ocr`(免 Key)或 `api`(外接视觉模型)
|
||||
@@ -99,6 +103,8 @@ cursor2api/
|
||||
│ ├── handler.ts # Anthropic API 处理器 + 身份保护 + 拒绝拦截 + Thinking
|
||||
│ ├── openai-handler.ts # OpenAI / Cursor IDE 兼容处理器 + response_format + Thinking
|
||||
│ ├── openai-types.ts # OpenAI 类型定义(含 response_format)
|
||||
│ ├── log-viewer.ts # 全链路日志 Web UI + 登录鉴权
|
||||
│ ├── logger.ts # 日志收集 + SSE 推送
|
||||
│ ├── proxy-agent.ts # 代理支持(全局 + Vision 独立代理)
|
||||
│ └── tool-fixer.ts # 工具参数自动修复(字段映射 + 智能引号 + 模糊匹配)
|
||||
├── test/
|
||||
|
||||
23
config.yaml
23
config.yaml
@@ -26,6 +26,29 @@ timeout: 120
|
||||
# Cursor 使用的模型
|
||||
cursor_model: "anthropic/claude-sonnet-4.6"
|
||||
|
||||
# ==================== 历史消息压缩配置 ====================
|
||||
# 对话过长时自动压缩早期消息,释放输出空间,防止 Cursor 上下文溢出
|
||||
# 压缩算法会智能识别消息类型,不会破坏工具调用的 JSON 结构
|
||||
compression:
|
||||
# 是否启用压缩(true/false),关闭后所有消息原样保留
|
||||
# 环境变量: COMPRESSION_ENABLED=true|false
|
||||
enabled: true
|
||||
|
||||
# 压缩级别: 1=轻度, 2=中等(默认), 3=激进
|
||||
# 环境变量: COMPRESSION_LEVEL=1|2|3
|
||||
# 级别说明:
|
||||
# 1(轻度): 保留最近 10 条消息,早期消息保留 4000 字符,适合短对话
|
||||
# 2(中等): 保留最近 6 条消息,早期消息保留 2000 字符,推荐日常使用
|
||||
# 3(激进): 保留最近 4 条消息,早期消息保留 1000 字符,适合超长对话/大工具集
|
||||
level: 2
|
||||
|
||||
# 以下为高级选项,设置后会覆盖 level 的预设值
|
||||
# 保留最近 N 条消息不压缩(数字越大保留越多上下文)
|
||||
# keep_recent: 6
|
||||
|
||||
# 早期消息最大字符数(超过此长度的消息会被智能压缩)
|
||||
# early_msg_max_chars: 2000
|
||||
|
||||
# 浏览器指纹配置
|
||||
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"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const txt = fs.readFileSync('last_payload.json', 'utf8');
|
||||
const payload = JSON.parse(txt);
|
||||
payload.forEach((m, i) => {
|
||||
const text = m.parts && m.parts[0] && m.parts[0].text ? m.parts[0].text : '';
|
||||
console.log(`--- Message ${i} [${m.role}] ---`);
|
||||
console.log(text.substring(0, 300));
|
||||
console.log('');
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "<available-deferred-tools>\nAgent\nAskUserQuestion\nBash\nEdit\nEnterPlanMode\nEnterWorktree\nExitPlanMode\nGlob\nGrep\nListMcpResourcesTool\nNotebookEdit\nRead\nReadMcpResourceTool\nSkill\nTaskOutput\nTaskStop\nTodoWrite\nWebFetch\nWebSearch\nWrite\nmcp__EMQX__get_mqtt_client\nmcp__EMQX__kick_mqtt_client\nmcp__EMQX__list_mqtt_clients\nmcp__EMQX__publish_mqtt_message\nmcp__context7__query-docs\nmcp__context7__resolve-library-id\nmcp__fetch__fetch\nmcp__filesystem__create_directory\nmcp__filesystem__directory_tree\nmcp__filesystem__edit_file\nmcp__filesystem__get_file_info\nmcp__filesystem__list_allowed_directories\nmcp__filesystem__list_directory\nmcp__filesystem__list_directory_with_sizes\nmcp__filesystem__move_file\nmcp__filesystem__read_file\nmcp__filesystem__read_media_file\nmcp__filesystem__read_multiple_files\nmcp__filesystem__read_text_file\nmcp__filesystem__search_files\nmcp__filesystem__write_file\nmcp__git__git_add\nmcp__git__git_blame\nmcp__git__git_branch\nmcp__git__git_changelog_analyze\nmcp__git__git_checkout\nmcp__git__git_cherry_pick\nmcp__git__git_clean\nmcp__git__git_clear_working_dir\nmcp__git__git_clone\nmcp__git__git_commit\nmcp__git__git_diff\nmcp__git__git_fetch\nmcp__git__git_init\nmcp__git__git_log\nmcp__git__git_merge\nmcp__git__git_pull\nmcp__git__git_push\nmcp__git__git_rebase\nmcp__git__git_reflog\nmcp__git__git_remote\nmcp__git__git_reset\nmcp__git__git_set_working_dir\nmcp__git__git_show\nmcp__git__git_stash\nmcp__git__git_status\nmcp__git__git_tag\nmcp__git__git_worktree\nmcp__git__git_wrapup_instructions\nmcp__memory__add_observations\nmcp__memory__create_entities\nmcp__memory__create_relations\nmcp__memory__delete_entities\nmcp__memory__delete_observations\nmcp__memory__delete_relations\nmcp__memory__open_nodes\nmcp__memory__read_graph\nmcp__memory__search_nodes\nmcp__mysql__describe_table\nmcp__mysql__execute_query\nmcp__mysql__list_databases\nmcp__mysql__list_tables\nmcp__redis__delete\nmcp__redis__get\nmcp__redis__list\nmcp__redis__set\nmcp__sequential-thinking__sequentialthinking\nmcp__time__convert_time\nmcp__time__get_current_time\n</available-deferred-tools>"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "<system-reminder>\nThe following skills are available for use with the Skill tool:\n\n- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: \"rebind ctrl+s\", \"add a chord shortcut\", \"change the submit key\", \"customize keybindings\".\n- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.\n- claude-api: Build apps with the Claude API or Anthropic SDK.\nTRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`/`claude_agent_sdk`, or user asks to use Claude API, Anthropic SDKs, or Agent SDK.\nDO NOT TRIGGER when: code imports `openai`/other AI SDK, general programming, or ML/data-science tasks.\n</system-reminder>"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "<system-reminder>\nAs you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is 2026-03-05.\n\n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n</system-reminder>\n"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "'搜一下今天的财经新闻"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_ec3c018ca1e149c8a2620a41",
|
||||
"name": "WebSearch",
|
||||
"input": {
|
||||
"query": "财经新闻 2026年3月5日",
|
||||
"max_results": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "<tool_use_error>InputValidationError: WebSearch failed due to the following issue:\nAn unexpected parameter `max_results` was provided</tool_use_error>",
|
||||
"is_error": true,
|
||||
"tool_use_id": "toolu_ec3c018ca1e149c8a2620a41"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_df8e58147af54616a6ede618",
|
||||
"name": "WebSearch",
|
||||
"input": {
|
||||
"query": "财经新闻 2026年3月5日"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Claude requested permissions to use WebSearch, but you haven't granted it yet.",
|
||||
"is_error": true,
|
||||
"tool_use_id": "toolu_df8e58147af54616a6ede618"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "I don't have permission to use WebSearch. Let me try fetching financial news via the fetch tool instead."
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_948d45dec8d64dac9544e4f1",
|
||||
"name": "ToolSearch",
|
||||
"input": {
|
||||
"query": "select:mcp__fetch__fetch"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_948d45dec8d64dac9544e4f1",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_reference",
|
||||
"tool_name": "mcp__fetch__fetch"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_32f8968c67dd4efcbfec7bed",
|
||||
"name": "mcp__fetch__fetch",
|
||||
"input": {
|
||||
"url": "https://finance.sina.com.cn/",
|
||||
"max_length": 5000
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Claude requested permissions to use mcp__fetch__fetch, but you haven't granted it yet.",
|
||||
"is_error": true,
|
||||
"tool_use_id": "toolu_32f8968c67dd4efcbfec7bed",
|
||||
"cache_control": {
|
||||
"type": "ephemeral"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor2api",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"description": "Proxy Cursor docs AI to Anthropic Messages API for Claude Code",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -45,6 +45,16 @@ export function getConfig(): AppConfig {
|
||||
? yaml.auth_tokens.map(String)
|
||||
: String(yaml.auth_tokens).split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
}
|
||||
// ★ 历史压缩配置
|
||||
if (yaml.compression !== undefined) {
|
||||
const c = yaml.compression;
|
||||
config.compression = {
|
||||
enabled: c.enabled !== false, // 默认启用
|
||||
level: [1, 2, 3].includes(c.level) ? c.level : 2,
|
||||
keepRecent: typeof c.keep_recent === 'number' ? c.keep_recent : 6,
|
||||
earlyMsgMaxChars: typeof c.early_msg_max_chars === 'number' ? c.early_msg_max_chars : 2000,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Config] 读取 config.yaml 失败:', e);
|
||||
}
|
||||
@@ -58,6 +68,16 @@ export function getConfig(): AppConfig {
|
||||
if (process.env.AUTH_TOKEN) {
|
||||
config.authTokens = process.env.AUTH_TOKEN.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
// 压缩环境变量覆盖
|
||||
if (process.env.COMPRESSION_ENABLED !== undefined) {
|
||||
if (!config.compression) config.compression = { enabled: true, level: 2, keepRecent: 6, earlyMsgMaxChars: 2000 };
|
||||
config.compression.enabled = process.env.COMPRESSION_ENABLED !== 'false' && process.env.COMPRESSION_ENABLED !== '0';
|
||||
}
|
||||
if (process.env.COMPRESSION_LEVEL) {
|
||||
if (!config.compression) config.compression = { enabled: true, level: 2, keepRecent: 6, earlyMsgMaxChars: 2000 };
|
||||
const lvl = parseInt(process.env.COMPRESSION_LEVEL);
|
||||
if (lvl >= 1 && lvl <= 3) config.compression.level = lvl as 1 | 2 | 3;
|
||||
}
|
||||
|
||||
// 从 base64 FP 环境变量解析指纹
|
||||
if (process.env.FP) {
|
||||
|
||||
@@ -345,20 +345,78 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise<Cur
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 渐进式历史压缩(替代之前全删的智能压缩)
|
||||
// 策略:保留最近 KEEP_RECENT 条消息完整,仅压缩早期消息中的超长文本
|
||||
// 这不会丢失消息结构(不删消息),只缩短单条消息的文本,兼顾上下文完整性和输出空间
|
||||
const KEEP_RECENT = 6; // 保留最近6条消息不压缩
|
||||
const EARLY_MSG_MAX_CHARS = 2000; // 早期消息的最大字符数
|
||||
if (messages.length > KEEP_RECENT + 2) { // +2 for few-shot messages
|
||||
const compressEnd = messages.length - KEEP_RECENT;
|
||||
for (let i = 2; i < compressEnd; i++) { // 从 index 2 开始跳过 few-shot
|
||||
const msg = messages[i];
|
||||
for (const part of msg.parts) {
|
||||
if (part.text && part.text.length > EARLY_MSG_MAX_CHARS) {
|
||||
// ★ 渐进式历史压缩(智能压缩,不破坏结构)
|
||||
// 可通过 config.yaml 的 compression 配置控制开关和级别
|
||||
// 策略:保留最近 KEEP_RECENT 条消息完整,对早期消息进行结构感知压缩
|
||||
// - 包含 json action 块的 assistant 消息 → 摘要替代(防止截断 JSON 导致解析错误)
|
||||
// - 工具结果消息 → 头尾保留(错误信息经常在末尾)
|
||||
// - 普通文本 → 在自然边界处截断
|
||||
const compressionConfig = config.compression ?? { enabled: true, level: 2 as const, keepRecent: 6, earlyMsgMaxChars: 2000 };
|
||||
if (compressionConfig.enabled) {
|
||||
// ★ 压缩级别参数映射:
|
||||
// Level 1(轻度): 保留更多消息和更多字符
|
||||
// Level 2(中等): 默认平衡模式
|
||||
// Level 3(激进): 极度压缩,最大化输出空间
|
||||
const levelParams = {
|
||||
1: { keepRecent: 10, maxChars: 4000, briefTextLen: 800 }, // 轻度
|
||||
2: { keepRecent: 6, maxChars: 2000, briefTextLen: 500 }, // 中等(默认)
|
||||
3: { keepRecent: 4, maxChars: 1000, briefTextLen: 200 }, // 激进
|
||||
};
|
||||
const lp = levelParams[compressionConfig.level] || levelParams[2];
|
||||
|
||||
// 用户自定义值覆盖级别预设
|
||||
const KEEP_RECENT = compressionConfig.keepRecent ?? lp.keepRecent;
|
||||
const EARLY_MSG_MAX_CHARS = compressionConfig.earlyMsgMaxChars ?? lp.maxChars;
|
||||
const BRIEF_TEXT_LEN = lp.briefTextLen;
|
||||
|
||||
const fewShotOffset = hasTools ? 2 : 0; // 工具模式有2条 few-shot 消息需跳过
|
||||
if (messages.length > KEEP_RECENT + fewShotOffset) {
|
||||
const compressEnd = messages.length - KEEP_RECENT;
|
||||
for (let i = fewShotOffset; i < compressEnd; i++) {
|
||||
const msg = messages[i];
|
||||
for (const part of msg.parts) {
|
||||
if (!part.text || part.text.length <= EARLY_MSG_MAX_CHARS) continue;
|
||||
const originalLen = part.text.length;
|
||||
part.text = part.text.substring(0, EARLY_MSG_MAX_CHARS) +
|
||||
`\n\n... [truncated ${originalLen - EARLY_MSG_MAX_CHARS} chars for context budget]`;
|
||||
|
||||
// ★ 包含工具调用的 assistant 消息:提取工具名摘要,不做子串截断
|
||||
// 截断 JSON action 块会产生未闭合的 ``` 和不完整 JSON,严重误导模型
|
||||
if (msg.role === 'assistant' && part.text.includes('```json')) {
|
||||
const toolSummaries: string[] = [];
|
||||
const toolPattern = /```json\s+action\s*\n\s*\{[\s\S]*?"tool"\s*:\s*"([^"]+)"[\s\S]*?```/g;
|
||||
let tm;
|
||||
while ((tm = toolPattern.exec(part.text)) !== null) {
|
||||
toolSummaries.push(tm[1]);
|
||||
}
|
||||
// 提取工具调用之外的纯文本(思考、解释等),按级别保留不同长度
|
||||
const plainText = part.text.replace(/```json\s+action[\s\S]*?```/g, '').trim();
|
||||
const briefText = plainText.length > BRIEF_TEXT_LEN ? plainText.substring(0, BRIEF_TEXT_LEN) + '...' : plainText;
|
||||
const summary = toolSummaries.length > 0
|
||||
? `${briefText}\n\n[Executed: ${toolSummaries.join(', ')}] (${originalLen} chars compressed)`
|
||||
: briefText + `\n\n... [${originalLen} chars compressed]`;
|
||||
part.text = summary;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ★ 工具结果(user 消息含 "Action output:"):头尾保留
|
||||
// 错误信息、命令输出的关键内容经常出现在末尾
|
||||
if (msg.role === 'user' && /Action (?:output|error)/i.test(part.text)) {
|
||||
const headBudget = Math.floor(EARLY_MSG_MAX_CHARS * 0.6);
|
||||
const tailBudget = EARLY_MSG_MAX_CHARS - headBudget;
|
||||
const omitted = originalLen - headBudget - tailBudget;
|
||||
part.text = part.text.substring(0, headBudget) +
|
||||
`\n\n... [${omitted} chars omitted] ...\n\n` +
|
||||
part.text.substring(originalLen - tailBudget);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ★ 普通文本:在自然边界(换行符)处截断,避免切断单词或代码
|
||||
let cutPos = EARLY_MSG_MAX_CHARS;
|
||||
const lastNewline = part.text.lastIndexOf('\n', EARLY_MSG_MAX_CHARS);
|
||||
if (lastNewline > EARLY_MSG_MAX_CHARS * 0.7) {
|
||||
cutPos = lastNewline; // 在最近的换行符处截断
|
||||
}
|
||||
part.text = part.text.substring(0, cutPos) +
|
||||
`\n\n... [truncated ${originalLen - cutPos} chars for context budget]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,11 +484,16 @@ function extractToolResultNatural(msg: AnthropicMessage): string {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ★ 动态截断:根据当前上下文大小计算预算
|
||||
// ★ 动态截断:根据当前上下文大小计算预算,使用头尾保留策略
|
||||
// 头部保留 60%,尾部保留 40%(错误信息、文件末尾内容经常很重要)
|
||||
const budget = getCurrentToolResultBudget();
|
||||
if (resultText.length > budget) {
|
||||
const truncated = resultText.slice(0, budget);
|
||||
resultText = truncated + `\n\n... (truncated, ${resultText.length} → ${budget} chars, context=${_currentContextChars})`;
|
||||
const headBudget = Math.floor(budget * 0.6);
|
||||
const tailBudget = budget - headBudget;
|
||||
const omitted = resultText.length - headBudget - tailBudget;
|
||||
resultText = resultText.slice(0, headBudget) +
|
||||
`\n\n... [${omitted} chars omitted, showing first ${headBudget} + last ${tailBudget} of ${resultText.length} chars] ...\n\n` +
|
||||
resultText.slice(-tailBudget);
|
||||
}
|
||||
|
||||
if (block.is_error) {
|
||||
|
||||
@@ -708,8 +708,17 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
|
||||
}
|
||||
|
||||
// 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
|
||||
// ★ 关键:拒绝检测必须在 thinking-stripped 文本上进行
|
||||
// 否则 thinking 中的反思性语言(如 "haven't given a specific task")会触发误判
|
||||
const getTextForRefusalCheck = () => {
|
||||
if (fullResponse.includes('<thinking>')) {
|
||||
return fullResponse.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim();
|
||||
}
|
||||
return fullResponse;
|
||||
};
|
||||
const shouldRetryRefusal = () => {
|
||||
if (!isRefusal(fullResponse)) return false;
|
||||
const textToCheck = getTextForRefusalCheck();
|
||||
if (!isRefusal(textToCheck)) return false;
|
||||
if (hasTools && hasToolCalls(fullResponse)) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -973,9 +982,10 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
|
||||
// ★ 仅对短响应或开头明确匹配拒绝模式的响应进行压制
|
||||
// 长响应(如模型在写报告)中可能碰巧包含某个宽泛的拒绝关键词,不应被误判
|
||||
// 截断响应(stopReason=max_tokens)一定不是拒绝
|
||||
const isShortResponse = fullResponse.trim().length < 500;
|
||||
const startsWithRefusal = isRefusal(fullResponse.substring(0, 300));
|
||||
const isActualRefusal = stopReason !== 'max_tokens' && (isShortResponse ? isRefusal(fullResponse) : startsWithRefusal);
|
||||
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);
|
||||
|
||||
if (isActualRefusal) {
|
||||
log.info('Handler', 'sanitize', `抑制无工具的完整拒绝响应`, { preview: fullResponse.substring(0, 200) });
|
||||
@@ -1098,7 +1108,17 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body
|
||||
}
|
||||
|
||||
// 拒绝检测 + 自动重试(工具模式和非工具模式均生效)
|
||||
const shouldRetry = () => isRefusal(fullText) && !(hasTools && hasToolCalls(fullText));
|
||||
// ★ 关键:拒绝检测必须在 thinking-stripped 文本上进行
|
||||
const getTextForRefusalCheck = () => {
|
||||
if (fullText.includes('<thinking>')) {
|
||||
return fullText.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '').trim();
|
||||
}
|
||||
return fullText;
|
||||
};
|
||||
const shouldRetry = () => {
|
||||
const textToCheck = getTextForRefusalCheck();
|
||||
return isRefusal(textToCheck) && !(hasTools && hasToolCalls(fullText));
|
||||
};
|
||||
|
||||
if (shouldRetry()) {
|
||||
for (let attempt = 0; attempt < MAX_REFUSAL_RETRIES; attempt++) {
|
||||
@@ -1289,9 +1309,10 @@ Continue EXACTLY from where you stopped. DO NOT repeat any content already gener
|
||||
} else {
|
||||
let textToSend = fullText;
|
||||
// ★ 同样仅对短响应或开头匹配的进行拒绝压制
|
||||
const isShort = fullText.trim().length < 500;
|
||||
const startsRefusal = isRefusal(fullText.substring(0, 300));
|
||||
const isRealRefusal = stopReason !== 'max_tokens' && (isShort ? isRefusal(fullText) : startsRefusal);
|
||||
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);
|
||||
if (isRealRefusal) {
|
||||
log.info('Handler', 'sanitize', `非流式抑制纯文本拒绝响应`, { preview: fullText.substring(0, 200) });
|
||||
textToSend = 'Let me proceed with the task.';
|
||||
|
||||
38
src/index.ts
38
src/index.ts
@@ -11,7 +11,7 @@ import express from 'express';
|
||||
import { getConfig } from './config.js';
|
||||
import { handleMessages, listModels, countTokens } from './handler.js';
|
||||
import { handleOpenAIChatCompletions, handleOpenAIResponses } from './openai-handler.js';
|
||||
import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream } from './log-viewer.js';
|
||||
import { serveLogViewer, apiGetLogs, apiGetRequests, apiGetStats, apiGetPayload, apiLogsStream, serveLogViewerLogin } from './log-viewer.js';
|
||||
|
||||
// 从 package.json 读取版本号,统一来源,避免多处硬编码
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -36,13 +36,35 @@ app.use((_req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// ★ 日志查看器(在鉴权之前,始终可访问)
|
||||
app.get('/logs', serveLogViewer);
|
||||
app.get('/api/logs', apiGetLogs);
|
||||
app.get('/api/requests', apiGetRequests);
|
||||
app.get('/api/stats', apiGetStats);
|
||||
app.get('/api/payload/:requestId', apiGetPayload);
|
||||
app.get('/api/logs/stream', apiLogsStream);
|
||||
// ★ 日志查看器鉴权中间件:配置了 authTokens 时需要验证
|
||||
const logViewerAuth = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
const tokens = config.authTokens;
|
||||
if (!tokens || tokens.length === 0) return next(); // 未配置 token 则放行
|
||||
|
||||
// 支持多种传入方式: query ?token=xxx, Authorization header, x-api-key header
|
||||
const tokenFromQuery = req.query.token as string | undefined;
|
||||
const authHeader = req.headers['authorization'] || req.headers['x-api-key'];
|
||||
const tokenFromHeader = authHeader ? String(authHeader).replace(/^Bearer\s+/i, '').trim() : undefined;
|
||||
const token = tokenFromQuery || tokenFromHeader;
|
||||
|
||||
if (!token || !tokens.includes(token)) {
|
||||
// HTML 页面请求 → 返回登录页; API 请求 → 返回 JSON 错误
|
||||
if (req.path === '/logs') {
|
||||
return serveLogViewerLogin(req, res);
|
||||
}
|
||||
res.status(401).json({ error: { message: 'Unauthorized. Provide token via ?token=xxx or Authorization header.', type: 'auth_error' } });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// ★ 日志查看器路由(带鉴权)
|
||||
app.get('/logs', logViewerAuth, serveLogViewer);
|
||||
app.get('/api/logs', logViewerAuth, apiGetLogs);
|
||||
app.get('/api/requests', logViewerAuth, apiGetRequests);
|
||||
app.get('/api/stats', logViewerAuth, apiGetStats);
|
||||
app.get('/api/payload/:requestId', logViewerAuth, apiGetPayload);
|
||||
app.get('/api/logs/stream', logViewerAuth, apiLogsStream);
|
||||
|
||||
// ★ API 鉴权中间件:配置了 authTokens 则需要 Bearer token
|
||||
app.use((req, res, next) => {
|
||||
|
||||
@@ -59,6 +59,77 @@ export function serveLogViewer(_req: Request, res: Response): void {
|
||||
res.send(LOG_VIEWER_HTML);
|
||||
}
|
||||
|
||||
export function serveLogViewerLogin(_req: Request, res: Response): void {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(LOGIN_HTML);
|
||||
}
|
||||
|
||||
// ==================== Login Page HTML ====================
|
||||
|
||||
const LOGIN_HTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cursor2API - 登录</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter',sans-serif;background:#080c14;color:#e2e8f0;height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
body::before{content:'';position:fixed;inset:0;background:radial-gradient(600px 400px at 50% 40%,rgba(59,130,246,.08),transparent 70%),radial-gradient(400px 300px at 70% 70%,rgba(139,92,246,.06),transparent 70%);pointer-events:none}
|
||||
.card{position:relative;z-index:1;width:380px;padding:40px;background:rgba(15,21,32,.95);border:1px solid rgba(30,58,95,.6);border-radius:16px;backdrop-filter:blur(20px);box-shadow:0 25px 50px rgba(0,0,0,.4)}
|
||||
.logo{text-align:center;margin-bottom:28px}
|
||||
.logo h1{font-size:22px;font-weight:700;background:linear-gradient(135deg,#06b6d4,#3b82f6,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.logo p{font-size:12px;color:#64748b;margin-top:6px}
|
||||
.field{margin-bottom:20px}
|
||||
.field label{display:block;font-size:11px;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
|
||||
.field input{width:100%;padding:10px 14px;font-size:13px;background:#0f1520;border:1px solid #1e3a5f;border-radius:8px;color:#e2e8f0;outline:none;font-family:'JetBrains Mono',monospace;transition:border-color .2s}
|
||||
.field input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
|
||||
.field input::placeholder{color:#475569}
|
||||
.btn{width:100%;padding:10px;font-size:13px;font-weight:600;background:linear-gradient(135deg,#3b82f6,#8b5cf6);border:none;border-radius:8px;color:#fff;cursor:pointer;transition:opacity .2s,transform .1s}
|
||||
.btn:hover{opacity:.9}.btn:active{transform:scale(.98)}
|
||||
.err{margin-top:12px;padding:8px 12px;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.2);border-radius:6px;font-size:11px;color:#ef4444;display:none;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<h1>⚡ Cursor2API</h1>
|
||||
<p>日志查看器需要验证身份</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Auth Token</label>
|
||||
<input type="password" id="tokenIn" placeholder="sk-your-token..." autofocus />
|
||||
</div>
|
||||
<button class="btn" onclick="doLogin()">登录</button>
|
||||
<div class="err" id="errMsg">Token 无效,请检查后重试</div>
|
||||
</div>
|
||||
<script>
|
||||
// 检查 localStorage 是否已有 token,自动尝试登录
|
||||
const saved = localStorage.getItem('cursor2api_token');
|
||||
if (saved) {
|
||||
window.location.href = '/logs?token=' + encodeURIComponent(saved);
|
||||
}
|
||||
document.getElementById('tokenIn').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
async function doLogin() {
|
||||
const token = document.getElementById('tokenIn').value.trim();
|
||||
if (!token) return;
|
||||
try {
|
||||
const r = await fetch('/api/stats?token=' + encodeURIComponent(token));
|
||||
if (r.ok) {
|
||||
localStorage.setItem('cursor2api_token', token);
|
||||
window.location.href = '/logs?token=' + encodeURIComponent(token);
|
||||
} else {
|
||||
document.getElementById('errMsg').style.display = 'block';
|
||||
}
|
||||
} catch {
|
||||
document.getElementById('errMsg').style.display = 'block';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ==================== HTML ====================
|
||||
|
||||
const LOG_VIEWER_HTML = `<!DOCTYPE html>
|
||||
@@ -274,19 +345,36 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(600px
|
||||
let reqs=[],rmap={},logs=[],selId=null,cFil='all',cLv='all',sq='',curTab='logs',curPayload=null;
|
||||
const PC={receive:'var(--blue)',convert:'var(--cyan)',send:'var(--purple)',response:'var(--purple)',thinking:'#a855f7',refusal:'var(--yellow)',retry:'var(--yellow)',truncation:'var(--yellow)',continuation:'var(--yellow)',toolparse:'var(--orange)',sanitize:'var(--orange)',stream:'var(--green)',complete:'var(--green)',error:'var(--red)',intercept:'var(--pink)',auth:'var(--t3)'};
|
||||
|
||||
// ★ Token 管理:从 URL 参数获取并存入 localStorage
|
||||
const urlToken = new URLSearchParams(window.location.search).get('token');
|
||||
if (urlToken) localStorage.setItem('cursor2api_token', urlToken);
|
||||
const authToken = localStorage.getItem('cursor2api_token') || '';
|
||||
function authQ(base) { return authToken ? (base.includes('?') ? base + '&token=' : base + '?token=') + encodeURIComponent(authToken) : base; }
|
||||
function logoutBtn() {
|
||||
if (authToken) {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = '退出';
|
||||
b.style.cssText = 'padding:2px 10px;font-size:10px;background:transparent;border:1px solid var(--bdr);border-radius:6px;color:var(--t2);cursor:pointer';
|
||||
b.onclick = () => { localStorage.removeItem('cursor2api_token'); window.location.href = '/logs'; };
|
||||
document.querySelector('.hdr-r').prepend(b);
|
||||
}
|
||||
}
|
||||
|
||||
async function init(){
|
||||
try{
|
||||
const[a,b]=await Promise.all([fetch('/api/requests?limit=100'),fetch('/api/logs?limit=500')]);
|
||||
const[a,b]=await Promise.all([fetch(authQ('/api/requests?limit=100')),fetch(authQ('/api/logs?limit=500'))]);
|
||||
if (a.status === 401) { localStorage.removeItem('cursor2api_token'); window.location.href = '/logs'; return; }
|
||||
reqs=await a.json();logs=await b.json();rmap={};reqs.forEach(r=>rmap[r.requestId]=r);
|
||||
renderRL();updCnt();updStats();
|
||||
}catch(e){console.error(e)}
|
||||
connectSSE();
|
||||
logoutBtn();
|
||||
}
|
||||
|
||||
let es;
|
||||
function connectSSE(){
|
||||
if(es)try{es.close()}catch{}
|
||||
es=new EventSource('/api/logs/stream');
|
||||
es=new EventSource(authQ('/api/logs/stream'));
|
||||
es.addEventListener('log',e=>{const en=JSON.parse(e.data);logs.push(en);if(logs.length>5000)logs=logs.slice(-3000);if(!selId||selId===en.requestId){if(curTab==='logs')appendLog(en)}});
|
||||
es.addEventListener('summary',e=>{const s=JSON.parse(e.data);const isNew=!rmap[s.requestId];rmap[s.requestId]=s;const i=reqs.findIndex(r=>r.requestId===s.requestId);if(i>=0)reqs[i]=s;else reqs.unshift(s);renderRL();updCnt();if(selId===s.requestId)renderSCard(s)});
|
||||
es.addEventListener('stats',e=>{applyStats(JSON.parse(e.data))});
|
||||
@@ -294,7 +382,7 @@ function connectSSE(){
|
||||
es.onerror=()=>{const c=document.getElementById('conn');c.className='conn off';c.querySelector('span').textContent='重连中...';setTimeout(connectSSE,3000)};
|
||||
}
|
||||
|
||||
function updStats(){fetch('/api/stats').then(r=>r.json()).then(applyStats).catch(()=>{})}
|
||||
function updStats(){fetch(authQ('/api/stats')).then(r=>r.json()).then(applyStats).catch(()=>{})}
|
||||
function applyStats(s){document.getElementById('sT').textContent=s.totalRequests;document.getElementById('sS').textContent=s.successCount;document.getElementById('sE').textContent=s.errorCount;document.getElementById('sA').textContent=s.avgResponseTime||'-';document.getElementById('sF').textContent=s.avgTTFT||'-'}
|
||||
|
||||
function updCnt(){
|
||||
@@ -329,7 +417,7 @@ async function selReq(id){
|
||||
document.getElementById('tabs').style.display='flex';
|
||||
curTab='logs';setTab('logs',document.querySelector('.tab'));
|
||||
// Load payload data
|
||||
try{const r=await fetch('/api/payload/'+id);if(r.ok)curPayload=await r.json();else curPayload=null}catch{curPayload=null}
|
||||
try{const r=await fetch(authQ('/api/payload/'+id));if(r.ok)curPayload=await r.json();else curPayload=null}catch{curPayload=null}
|
||||
// Render log tab
|
||||
const ll=logs.filter(l=>l.requestId===id);renderLogs(ll);
|
||||
}
|
||||
|
||||
@@ -186,10 +186,8 @@ 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 } }
|
||||
: {}),
|
||||
// ★ Thinking 默认启用:确保 Claude Code 等 OpenAI 格式客户端也能获得 thinking 内容
|
||||
thinking: { type: 'enabled' as const },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,12 @@ export interface AppConfig {
|
||||
model: string;
|
||||
proxy?: string; // vision 独立代理(不影响 Cursor API 直连)
|
||||
};
|
||||
compression?: {
|
||||
enabled: boolean; // 是否启用历史消息压缩
|
||||
level: 1 | 2 | 3; // 压缩级别: 1=轻度, 2=中等(默认), 3=激进
|
||||
keepRecent: number; // 保留最近 N 条消息不压缩
|
||||
earlyMsgMaxChars: number; // 早期消息最大字符数
|
||||
};
|
||||
fingerprint: {
|
||||
userAgent: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user