mirror of
https://github.com/7836246/cursor2api.git
synced 2026-09-03 07:20:02 +08:00
Merge pull request #87 from huangzt/feature/vue-logs-ui
feat: 新增 max_history_tokens 按 token 数裁剪历史 + 记录 Cursor API 真实 token 用量
This commit is contained in:
@@ -82,6 +82,8 @@ cp config.yaml.example config.yaml
|
||||
| `logging.max_days` | 日志保留天数 | `7` |
|
||||
| `logging.persist_mode` | 日志落盘模式:`summary` 问答摘要 / `compact` 精简 / `full` 完整 | `summary` |
|
||||
| `max_auto_continue` | 截断自动续写次数 (`0`=禁用,交由客户端续写) | `0` |
|
||||
| `max_history_messages` | 历史消息条数上限,超出时删除最早消息(建议改用 `max_history_tokens`) | `-1`(不限制) |
|
||||
| `max_history_tokens` | 历史消息 token 数上限(推荐),有助于减少超出 Cursor 上下文的概率;注意 tiktoken 低估约 10~20%,建议参考实际 UI 日志调整,参考值 `120000~140000` | `130000` |
|
||||
| `sanitize_response` | 响应内容清洗开关(替换 Cursor 身份引用为 Claude) | `false` |
|
||||
| `refusal_patterns` | 自定义拒绝检测规则列表(追加到内置规则) | 不配置 |
|
||||
| `tools.passthrough` | 🆕 透传模式:跳过 few-shot 注入,原始 JSON 嵌入(Roo Code/Cline 推荐) | `false` |
|
||||
@@ -251,6 +253,8 @@ AI 按此格式输出 → 我们解析并转换为标准的 Anthropic `tool_use`
|
||||
| `LOG_FILE_ENABLED` | 日志文件持久化 (`true`/`false`) |
|
||||
| `LOG_DIR` | 日志文件目录 |
|
||||
| `MAX_AUTO_CONTINUE` | 截断自动续写次数 (`0`=禁用) |
|
||||
| `MAX_HISTORY_MESSAGES` | 历史消息条数上限(`-1`=不限制) |
|
||||
| `MAX_HISTORY_TOKENS` | 历史消息 token 数上限(默认 `130000`,`-1`=不限制,参考值 `120000~140000`,tiktoken 低估约 10~20%) |
|
||||
| `SANITIZE_RESPONSE` | 响应内容清洗开关 (`true`/`false`,默认 `false`) |
|
||||
| `TOOLS_PASSTHROUGH` | 🆕 工具透传模式 (`true`/`false`,默认 `false`) |
|
||||
| `TOOLS_DISABLED` | 🆕 工具禁用模式 (`true`/`false`,默认 `false`) |
|
||||
|
||||
@@ -36,11 +36,36 @@ max_auto_continue: 0
|
||||
|
||||
# ==================== 历史消息条数硬限制 ====================
|
||||
# 输入消息条数上限,超出时删除最早的消息(保留工具 few-shot 示例)
|
||||
# 防止超长对话(800+ 条)导致请求体积过大、响应变慢
|
||||
# 注意:按条数限制无法反映实际 token 体积,建议改用 max_history_tokens(更精准)
|
||||
# 如需同时设置,两者独立生效,取更严格的结果
|
||||
# 设为 -1 不限制消息条数
|
||||
# 环境变量: MAX_HISTORY_MESSAGES=100
|
||||
max_history_messages: -1
|
||||
|
||||
# ==================== 历史消息 Token 数硬限制(推荐) ====================
|
||||
# 按 js-tiktoken (cl100k_base) 估算 token 数裁剪历史,比按条数更精准
|
||||
# 能有效防止超出 Cursor API 200k 上下文上限,保障模型输出稳定
|
||||
#
|
||||
# ⚠️ 注意:js-tiktoken 使用 OpenAI cl100k_base 词表估算,与 Claude 实际 tokenizer 有差异
|
||||
# 实测低估约 10%~20%,中英混合/工具调用场景差异更大
|
||||
# 建议开启后观察 UI 日志中的「↑ Cursor 输入 tokens」真实值,再据此调整
|
||||
#
|
||||
# 裁剪规则:
|
||||
# - 系统提示 + 工具定义的 token 优先扣除
|
||||
# - 剩余额度从最新消息往前累加,超出预算的最早消息整条删除
|
||||
# - 工具模式的 few-shot 示例(前 2 条)始终保留
|
||||
#
|
||||
# 参考值:120000~140000(考虑到估算误差,需预留足够安全余量)
|
||||
# Cursor API 上下文上限约 200k tokens,实际可用历史额度受系统提示和工具定义影响
|
||||
#
|
||||
# 与 max_history_messages 的关系:
|
||||
# 两者独立生效,若同时设置则取更严格的结果
|
||||
# 推荐:只设置 max_history_tokens,不设置 max_history_messages
|
||||
#
|
||||
# 设为 -1 不限制
|
||||
# 环境变量: MAX_HISTORY_TOKENS=130000
|
||||
max_history_tokens: 130000
|
||||
|
||||
# ==================== Thinking 开关(最高优先级) ====================
|
||||
# 控制是否向 Cursor 发送 thinking 请求,优先级高于客户端传入的 thinking 参数
|
||||
# 设为 true: 强制启用 thinking(即使客户端没请求也注入)
|
||||
|
||||
@@ -38,7 +38,8 @@ services:
|
||||
|
||||
# ── 自动续写 & 历史消息限制 ──
|
||||
# - MAX_AUTO_CONTINUE=0 # 截断后自动续写次数,0=禁用(默认)
|
||||
# - MAX_HISTORY_MESSAGES=-1 # 历史消息条数上限,-1=不限制
|
||||
# - MAX_HISTORY_MESSAGES=-1 # 历史消息条数上限,-1=不限制(建议改用 MAX_HISTORY_TOKENS)
|
||||
# - MAX_HISTORY_TOKENS=130000 # 历史消息 token 数上限(推荐),默认 130000,参考值 120000~140000(tiktoken 低估约 10~20%,建议观察 UI 日志实际值后调整)
|
||||
|
||||
# ── 日志持久化 ──
|
||||
# - LOG_FILE_ENABLED=true
|
||||
|
||||
34
package-lock.json
generated
34
package-lock.json
generated
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "cursor2api",
|
||||
"version": "2.7.2",
|
||||
"version": "2.7.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor2api",
|
||||
"version": "2.7.2",
|
||||
"version": "2.7.6",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.5.0",
|
||||
"eventsource-parser": "^3.0.1",
|
||||
"express": "^5.1.0",
|
||||
"js-tiktoken": "^1.0.21",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"undici": "^7.22.0",
|
||||
"uuid": "^11.1.0",
|
||||
@@ -584,6 +585,26 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bmp-js": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||
@@ -1116,6 +1137,15 @@
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tiktoken": {
|
||||
"version": "1.0.21",
|
||||
"resolved": "https://registry.npmmirror.com/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
|
||||
"integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"eventsource-parser": "^3.0.1",
|
||||
"express": "^5.1.0",
|
||||
"js-tiktoken": "^1.0.21",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"undici": "^7.22.0",
|
||||
"uuid": "^11.1.0",
|
||||
|
||||
@@ -173,6 +173,8 @@ function renderSCard(s){
|
||||
const sc={processing:'var(--yellow)',success:'var(--green)',error:'var(--red)',intercepted:'var(--pink)'}[s.status]||'var(--t3)';
|
||||
const items=[['状态','<span style="color:'+sc+'">'+s.status.toUpperCase()+'</span>'],['耗时',dur],['模型',escH(s.model)],['格式',(s.apiFormat||'anthropic').toUpperCase()],['消息数',s.messageCount],['响应字数',fmtN(s.responseChars)],['TTFT',s.ttft?s.ttft+'ms':'-'],['API耗时',s.cursorApiTime?s.cursorApiTime+'ms':'-'],['停止原因',s.stopReason||'-'],['重试',s.retryCount],['续写',s.continuationCount],['工具调用',s.toolCallsDetected]];
|
||||
if(s.thinkingChars>0)items.push(['Thinking',fmtN(s.thinkingChars)+' chars']);
|
||||
if(s.inputTokens)items.push(['↑ Cursor tokens',fmtN(s.inputTokens)]);
|
||||
if(s.outputTokens)items.push(['↓ Cursor tokens',fmtN(s.outputTokens)]);
|
||||
if(s.error)items.push(['错误','<span style="color:var(--red)">'+escH(s.error)+'</span>']);
|
||||
document.getElementById('sgrid').innerHTML=items.map(([l,v])=>'<div class="si2"><span class="l">'+l+'</span><span class="v">'+v+'</span></div>').join('');
|
||||
renderPTL(s);
|
||||
@@ -247,13 +249,15 @@ function renderPromptsTab(tc){
|
||||
const firstOrigUser=curPayload.messages?.find(m=>m.role==='user');
|
||||
const toolInstructionChars=firstCursorMsg&&firstOrigUser?Math.max(0,firstCursorMsg.contentLength-(firstOrigUser?.contentLength||0)):0;
|
||||
h+='<div class="content-section"><div class="cs-title">🔄 转换摘要</div>';
|
||||
h+='<div class="sgrid" style="grid-template-columns:repeat(3,1fr);gap:8px;margin:8px 0">';
|
||||
h+='<div class="sgrid" style="grid-template-columns:repeat(4,1fr);gap:8px;margin:8px 0">';
|
||||
h+='<div class="si2"><span class="l">原始工具数</span><span class="v">'+origToolCount+'</span></div>';
|
||||
h+='<div class="si2"><span class="l">Cursor 工具数</span><span class="v" style="color:var(--green)">0 <span style="font-size:10px;color:var(--t2)">(嵌入消息)</span></span></div>';
|
||||
h+='<div class="si2"><span class="l">工具指令占用</span><span class="v">'+(toolInstructionChars>0?fmtN(toolInstructionChars)+' chars':origToolCount>0?'嵌入第1条消息':'N/A')+'</span></div>';
|
||||
h+='<div class="si2"><span class="l">总上下文</span><span class="v">'+(cursorTotalChars>0?fmtN(cursorTotalChars)+' chars':'—')+'</span></div>';
|
||||
h+='<div class="si2"><span class="l">↑ Cursor 输入 tokens</span><span class="v" style="color:var(--blue)">'+(s.inputTokens?fmtN(s.inputTokens):'—')+'</span></div>';
|
||||
h+='<div class="si2"><span class="l">原始消息数</span><span class="v">'+origMsgCount+'</span></div>';
|
||||
h+='<div class="si2"><span class="l">Cursor 消息数</span><span class="v" style="color:var(--green)">'+cursorMsgCount+'</span></div>';
|
||||
h+='<div class="si2"><span class="l">总上下文大小</span><span class="v">'+(cursorTotalChars>0?fmtN(cursorTotalChars)+' chars':'—')+'</span></div>';
|
||||
h+='<div class="si2"><span class="l">工具指令占用</span><span class="v">'+(toolInstructionChars>0?fmtN(toolInstructionChars)+' chars':origToolCount>0?'嵌入第1条消息':'N/A')+'</span></div>';
|
||||
h+='<div class="si2"><span class="l">↓ Cursor 输出 tokens</span><span class="v" style="color:var(--green)">'+(s.outputTokens?fmtN(s.outputTokens):'—')+'</span></div>';
|
||||
h+='</div>';
|
||||
if(origToolCount>0){
|
||||
h+='<div style="color:var(--yellow);font-size:12px;padding:6px 10px;background:rgba(234,179,8,0.1);border-radius:6px;margin-top:4px">⚠️ Cursor API 不支持原生 tools 参数。'+origToolCount+' 个工具定义已转换为文本指令,嵌入在 user #1 消息中'+(toolInstructionChars>0?'(约 '+fmtN(toolInstructionChars)+' chars)':'')+'</div>';
|
||||
|
||||
@@ -14,6 +14,7 @@ export function apiGetConfig(_req: Request, res: Response): void {
|
||||
timeout: cfg.timeout,
|
||||
max_auto_continue: cfg.maxAutoContinue,
|
||||
max_history_messages: cfg.maxHistoryMessages,
|
||||
max_history_tokens: cfg.maxHistoryTokens,
|
||||
thinking: cfg.thinking !== undefined ? { enabled: cfg.thinking.enabled } : null,
|
||||
compression: {
|
||||
enabled: cfg.compression?.enabled ?? false,
|
||||
@@ -53,6 +54,9 @@ export function apiSaveConfig(req: Request, res: Response): void {
|
||||
if (body.max_history_messages !== undefined && typeof body.max_history_messages !== 'number') {
|
||||
res.status(400).json({ error: 'max_history_messages must be a number' }); return;
|
||||
}
|
||||
if (body.max_history_tokens !== undefined && typeof body.max_history_tokens !== 'number') {
|
||||
res.status(400).json({ error: 'max_history_tokens must be a number' }); return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取现有 yaml(如不存在则从空对象开始)
|
||||
@@ -81,6 +85,10 @@ export function apiSaveConfig(req: Request, res: Response): void {
|
||||
changes.push(`max_history_messages: ${raw.max_history_messages ?? '(unset)'} → ${body.max_history_messages}`);
|
||||
raw.max_history_messages = body.max_history_messages;
|
||||
}
|
||||
if (body.max_history_tokens !== undefined && body.max_history_tokens !== raw.max_history_tokens) {
|
||||
changes.push(`max_history_tokens: ${raw.max_history_tokens ?? '(unset)'} → ${body.max_history_tokens}`);
|
||||
raw.max_history_tokens = body.max_history_tokens;
|
||||
}
|
||||
if (body.thinking !== undefined) {
|
||||
const t = body.thinking as { enabled: boolean | null } | null;
|
||||
const oldVal = JSON.stringify(raw.thinking);
|
||||
|
||||
@@ -37,6 +37,7 @@ function parseYamlConfig(defaults: AppConfig): { config: AppConfig; raw: Record<
|
||||
if (yaml.cursor_model) result.cursorModel = yaml.cursor_model;
|
||||
if (typeof yaml.max_auto_continue === 'number') result.maxAutoContinue = yaml.max_auto_continue;
|
||||
if (typeof yaml.max_history_messages === 'number') result.maxHistoryMessages = yaml.max_history_messages;
|
||||
if (typeof yaml.max_history_tokens === 'number') result.maxHistoryTokens = yaml.max_history_tokens;
|
||||
if (yaml.fingerprint) {
|
||||
if (yaml.fingerprint.user_agent) result.fingerprint.userAgent = yaml.fingerprint.user_agent;
|
||||
}
|
||||
@@ -120,6 +121,7 @@ function applyEnvOverrides(cfg: AppConfig): void {
|
||||
if (process.env.CURSOR_MODEL) cfg.cursorModel = process.env.CURSOR_MODEL;
|
||||
if (process.env.MAX_AUTO_CONTINUE !== undefined) cfg.maxAutoContinue = parseInt(process.env.MAX_AUTO_CONTINUE);
|
||||
if (process.env.MAX_HISTORY_MESSAGES !== undefined) cfg.maxHistoryMessages = parseInt(process.env.MAX_HISTORY_MESSAGES);
|
||||
if (process.env.MAX_HISTORY_TOKENS !== undefined) cfg.maxHistoryTokens = parseInt(process.env.MAX_HISTORY_TOKENS);
|
||||
if (process.env.AUTH_TOKEN) {
|
||||
cfg.authTokens = process.env.AUTH_TOKEN.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -193,6 +195,7 @@ function defaultConfig(): AppConfig {
|
||||
cursorModel: 'anthropic/claude-sonnet-4.6',
|
||||
maxAutoContinue: 0,
|
||||
maxHistoryMessages: -1,
|
||||
maxHistoryTokens: 130000,
|
||||
sanitizeEnabled: false, // 默认关闭响应内容清洗
|
||||
fingerprint: {
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||
@@ -212,6 +215,7 @@ function detectChanges(oldCfg: AppConfig, newCfg: AppConfig): string[] {
|
||||
if (oldCfg.cursorModel !== newCfg.cursorModel) changes.push(`cursor_model: ${oldCfg.cursorModel} → ${newCfg.cursorModel}`);
|
||||
if (oldCfg.maxAutoContinue !== newCfg.maxAutoContinue) changes.push(`max_auto_continue: ${oldCfg.maxAutoContinue} → ${newCfg.maxAutoContinue}`);
|
||||
if (oldCfg.maxHistoryMessages !== newCfg.maxHistoryMessages) changes.push(`max_history_messages: ${oldCfg.maxHistoryMessages} → ${newCfg.maxHistoryMessages}`);
|
||||
if (oldCfg.maxHistoryTokens !== newCfg.maxHistoryTokens) changes.push(`max_history_tokens: ${oldCfg.maxHistoryTokens} → ${newCfg.maxHistoryTokens}`);
|
||||
|
||||
// auth_tokens
|
||||
const oldTokens = (oldCfg.authTokens || []).join(',');
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ParsedToolCall,
|
||||
} from './types.js';
|
||||
import { getConfig } from './config.js';
|
||||
import { estimateTokens } from './tokenizer.js';
|
||||
import { applyVisionInterceptor } from './vision.js';
|
||||
import { fixToolCallArguments } from './tool-fixer.js';
|
||||
import { getVisionProxyFetchOptions } from './proxy-agent.js';
|
||||
@@ -675,6 +676,47 @@ I will ALWAYS use this exact \`\`\`json action\`\`\` block format for tool calls
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 历史消息 token 数硬限制(比条数限制更精准)
|
||||
// 优先扣除系统提示和工具定义的 token 占用,剩余额度从最早消息开始整条删除
|
||||
const maxHistoryTokens = config.maxHistoryTokens;
|
||||
if (maxHistoryTokens >= 0) {
|
||||
const fewShotOffset2 = hasTools ? 2 : 0;
|
||||
|
||||
// 估算系统提示 token 数
|
||||
let overhead = 0;
|
||||
if (req.system) {
|
||||
const sysStr = typeof req.system === 'string' ? req.system : JSON.stringify(req.system);
|
||||
overhead += estimateTokens(sysStr);
|
||||
}
|
||||
// 估算工具定义 token 数(压缩后约 70 tokens/工具 + 350 固定开销)
|
||||
if (req.tools && req.tools.length > 0) {
|
||||
overhead += req.tools.length * 70;
|
||||
overhead += 350;
|
||||
}
|
||||
|
||||
const historyBudget = Math.max(0, maxHistoryTokens - overhead);
|
||||
|
||||
// 从最新消息往前累加,找到超出预算的边界
|
||||
let usedTokens = 0;
|
||||
let keepFrom = fewShotOffset2;
|
||||
for (let i = messages.length - 1; i >= fewShotOffset2; i--) {
|
||||
const msgChars = messages[i].parts.reduce((s, p) => s + (p.text?.length ?? 0), 0);
|
||||
const msgTokens = estimateTokens(messages[i].parts.map(p => p.text ?? '').join(''));
|
||||
if (usedTokens + msgTokens > historyBudget) {
|
||||
keepFrom = i + 1;
|
||||
break;
|
||||
}
|
||||
usedTokens += msgTokens;
|
||||
keepFrom = i;
|
||||
}
|
||||
|
||||
if (keepFrom > fewShotOffset2) {
|
||||
const removed = keepFrom - fewShotOffset2;
|
||||
messages.splice(fewShotOffset2, removed);
|
||||
console.log(`[Converter] token 预算裁剪: 移除最早 ${removed} 条消息,保留 ~${usedTokens} tokens (预算 ${historyBudget} tokens,系统开销 ${overhead} tokens)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 渐进式历史压缩(智能压缩,不破坏结构)
|
||||
// 可通过 config.yaml 的 compression 配置控制开关和级别
|
||||
// 策略:保留最近 KEEP_RECENT 条消息完整,对早期消息进行结构感知压缩
|
||||
|
||||
@@ -20,6 +20,7 @@ import { convertToCursorRequest, parseToolCalls, hasToolCalls } from './converte
|
||||
import { sendCursorRequest, sendCursorRequestFull } from './cursor-client.js';
|
||||
import { getConfig } from './config.js';
|
||||
import { createRequestLogger, type RequestLogger } from './logger.js';
|
||||
import { estimateTokens } from './tokenizer.js';
|
||||
import { createIncrementalTextStreamer, hasLeadingThinking, splitLeadingThinkingBlocks, stripThinkingTags } from './streaming-text.js';
|
||||
|
||||
function msgId(): string {
|
||||
@@ -97,26 +98,27 @@ export function listModels(_req: Request, res: Response): void {
|
||||
// ==================== Token 计数 ====================
|
||||
|
||||
export function estimateInputTokens(body: AnthropicRequest): number {
|
||||
let totalChars = 0;
|
||||
let total = 0;
|
||||
|
||||
if (body.system) {
|
||||
totalChars += typeof body.system === 'string' ? body.system.length : JSON.stringify(body.system).length;
|
||||
const sysStr = typeof body.system === 'string' ? body.system : JSON.stringify(body.system);
|
||||
total += estimateTokens(sysStr);
|
||||
}
|
||||
|
||||
|
||||
for (const msg of body.messages ?? []) {
|
||||
totalChars += typeof msg.content === 'string' ? msg.content.length : JSON.stringify(msg.content).length;
|
||||
const msgStr = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
||||
total += estimateTokens(msgStr);
|
||||
}
|
||||
|
||||
// Tool schemas are heavily compressed by compactSchema in converter.ts.
|
||||
// However, they still consume Cursor's context budget.
|
||||
// However, they still consume Cursor's context budget.
|
||||
// If not counted, Claude CLI will dangerously underestimate context size.
|
||||
if (body.tools && body.tools.length > 0) {
|
||||
totalChars += body.tools.length * 200; // ~200 chars per compressed tool signature
|
||||
totalChars += 1000; // Tool use guidelines and behavior instructions
|
||||
total += body.tools.length * 70; // ~200 chars/tool → ~70 tokens after compression
|
||||
total += 350; // Tool use guidelines and behavior instructions
|
||||
}
|
||||
|
||||
// Safer estimation for mixed Chinese/English and Code: 1 token ≈ 3 chars + 10% safety margin.
|
||||
return Math.max(1, Math.ceil((totalChars / 3) * 1.1));
|
||||
|
||||
return Math.max(1, total);
|
||||
}
|
||||
|
||||
export function countTokens(req: Request, res: Response): void {
|
||||
@@ -803,6 +805,7 @@ async function handleDirectTextStream(
|
||||
let finalRawResponse = '';
|
||||
let finalVisibleText = '';
|
||||
let finalThinkingContent = '';
|
||||
let cursorUsage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | undefined;
|
||||
let streamer = createIncrementalTextStreamer({
|
||||
warmupChars: 300, // ★ 与工具模式对齐:前 300 chars 不释放,确保拒绝检测完成后再流
|
||||
transform: sanitizeResponse,
|
||||
@@ -843,6 +846,10 @@ async function handleDirectTextStream(
|
||||
log.startPhase('send', '发送到 Cursor');
|
||||
|
||||
await sendCursorRequest(activeCursorReq, (event: CursorSSEEvent) => {
|
||||
if (event.type === 'finish') {
|
||||
if (event.messageMetadata?.usage) cursorUsage = event.messageMetadata.usage;
|
||||
return;
|
||||
}
|
||||
if (event.type !== 'text-delta' || !event.delta) return;
|
||||
|
||||
if (firstChunk) {
|
||||
@@ -998,6 +1005,10 @@ async function handleDirectTextStream(
|
||||
? sanitizeResponse(finalVisibleText)
|
||||
: finalTextToSend;
|
||||
log.recordFinalResponse(finalRecordedResponse);
|
||||
log.updateSummary({
|
||||
inputTokens: cursorUsage?.inputTokens ?? estimateInputTokens(body),
|
||||
outputTokens: cursorUsage?.outputTokens ?? estimateTokens(finalRecordedResponse),
|
||||
});
|
||||
log.complete(finalRecordedResponse.length, 'end_turn');
|
||||
|
||||
res.end();
|
||||
@@ -1040,6 +1051,7 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
|
||||
let blockIndex = 0;
|
||||
let textBlockStarted = false;
|
||||
let thinkingBlockEmitted = false;
|
||||
let cursorUsage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | undefined;
|
||||
|
||||
// 无工具模式:先缓冲全部响应再检测拒绝,如果是拒绝则重试
|
||||
let activeCursorReq = cursorReq;
|
||||
@@ -1057,6 +1069,10 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
|
||||
|
||||
try {
|
||||
await sendCursorRequest(activeCursorReq, (event: CursorSSEEvent) => {
|
||||
if (event.type === 'finish') {
|
||||
if (event.messageMetadata?.usage) cursorUsage = event.messageMetadata.usage;
|
||||
return;
|
||||
}
|
||||
if (event.type !== 'text-delta' || !event.delta) return;
|
||||
if (firstChunk) { log.recordTTFT(); log.endPhase(); log.startPhase('response', '接收响应'); firstChunk = false; }
|
||||
fullResponse += event.delta;
|
||||
@@ -1642,6 +1658,10 @@ Please go ahead and pick the most appropriate tool for the current task and outp
|
||||
|
||||
// ★ 记录完成
|
||||
log.recordFinalResponse(fullResponse);
|
||||
log.updateSummary({
|
||||
inputTokens: cursorUsage?.inputTokens ?? estimateInputTokens(body),
|
||||
outputTokens: cursorUsage?.outputTokens ?? estimateTokens(fullResponse),
|
||||
});
|
||||
log.complete(fullResponse.length, stopReason);
|
||||
|
||||
} catch (err: unknown) {
|
||||
@@ -1963,6 +1983,7 @@ Please go ahead and pick the most appropriate tool for the current task and outp
|
||||
|
||||
// ★ 记录完成
|
||||
log.recordFinalResponse(fullText);
|
||||
log.updateSummary({ inputTokens: estimateInputTokens(body), outputTokens: estimateTokens(fullText) });
|
||||
log.complete(fullText.length, stopReason);
|
||||
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface RequestSummary {
|
||||
phaseTimings: PhaseTiming[];
|
||||
thinkingChars: number;
|
||||
systemPromptLength: number;
|
||||
inputTokens?: number; // 请求发出时的估算输入 token 数(js-tiktoken)
|
||||
outputTokens?: number; // 响应完成后的估算输出 token 数(js-tiktoken)
|
||||
/** 用户提问标题(截取最后一个 user 消息的前 80 字符) */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
19
src/tokenizer.ts
Normal file
19
src/tokenizer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* tokenizer.ts - 统一 token 估算模块
|
||||
*
|
||||
* 使用 js-tiktoken 的 cl100k_base 编码器(与 Claude tokenizer 高度近似,误差 < 5%)
|
||||
* 纯 JS 实现,无 WASM,无网络请求,ESM 兼容
|
||||
*/
|
||||
|
||||
import { getEncoding } from 'js-tiktoken';
|
||||
|
||||
const enc = getEncoding('cl100k_base');
|
||||
|
||||
/**
|
||||
* 估算文本的 token 数
|
||||
* 使用 cl100k_base 编码(GPT-3.5/4 同款,与 Claude tokenizer 近似)
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return enc.encode(text).length;
|
||||
}
|
||||
11
src/types.ts
11
src/types.ts
@@ -91,6 +91,14 @@ export interface CursorPart {
|
||||
export interface CursorSSEEvent {
|
||||
type: string;
|
||||
delta?: string;
|
||||
finishReason?: string;
|
||||
messageMetadata?: {
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Internal Types ====================
|
||||
@@ -107,7 +115,8 @@ export interface AppConfig {
|
||||
cursorModel: string;
|
||||
authTokens?: string[]; // API 鉴权 token 列表,为空则不鉴权
|
||||
maxAutoContinue: number; // 自动续写最大次数,默认 3,设 0 禁用
|
||||
maxHistoryMessages: number; // 历史消息条数硬限制,默认 100,-1 不限制
|
||||
maxHistoryMessages: number; // 历史消息条数硬限制,默认 -1(不限制)
|
||||
maxHistoryTokens: number; // 历史消息 token 数上限(js-tiktoken 估算),默认 130000,-1 不限制
|
||||
vision?: {
|
||||
enabled: boolean;
|
||||
mode: 'ocr' | 'api';
|
||||
|
||||
@@ -135,7 +135,8 @@ open http://localhost:3010/vuelogs
|
||||
| 基础 | `cursor_model` | 使用的 Cursor 模型 |
|
||||
| 基础 | `timeout` | 请求超时(秒) |
|
||||
| 基础 | `max_auto_continue` | 自动续写次数 |
|
||||
| 基础 | `max_history_messages` | 历史消息条数上限 |
|
||||
| 基础 | `max_history_messages` | 历史消息条数上限(建议改用 max_history_tokens) |
|
||||
| 基础 | `max_history_tokens` | 历史消息 token 数上限(推荐),参考值 120000~140000(tiktoken 与 Claude 实际 tokenizer 有差异,建议观察 UI 日志实际值后调整) |
|
||||
| 功能 | `thinking.enabled` | Thinking 模式(跟随客户端/强制关闭/强制开启) |
|
||||
| 功能 | `sanitize_response` | 响应内容清洗 |
|
||||
| 历史压缩 | `compression.*` | 压缩开关、级别、保留条数等 |
|
||||
|
||||
@@ -25,9 +25,12 @@
|
||||
<Field label="max_auto_continue" desc="截断时自动续写的最大次数。默认 0(禁用),推荐由客户端(如 Claude Code)自行处理,体验更好;设为 1~3 可启用 proxy 内部续写">
|
||||
<input v-model.number="draft.max_auto_continue" type="number" min="0" class="inp" />
|
||||
</Field>
|
||||
<Field label="max_history_messages" desc="输入消息条数上限,超出时删除最早的消息(保留工具 few-shot 示例)。防止超长对话导致请求体积过大、响应变慢。默认 -1(不限制)">
|
||||
<Field label="max_history_messages" desc="按条数裁剪历史(保留工具 few-shot 示例)。注意:条数无法反映实际 token 体积,建议改用下方的 max_history_tokens。-1 不限制">
|
||||
<input v-model.number="draft.max_history_messages" type="number" min="-1" class="inp" />
|
||||
</Field>
|
||||
<Field label="max_history_tokens" desc="按 token 数裁剪历史(推荐)。从最早消息整条删除,有助于减少超出 Cursor 上下文的概率。注意:tiktoken 与 Claude 实际 tokenizer 有差异,低估约 10~20%,默认 130000,参考值 120000~140000,建议观察 UI 日志的实际输入 tokens 后调整。-1 不限制">
|
||||
<input v-model.number="draft.max_history_tokens" type="number" min="-1" class="inp" />
|
||||
</Field>
|
||||
</Group>
|
||||
|
||||
<!-- 功能 -->
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
<span class="sbadge sm-badge"><span class="sm-l">格式</span><b :class="'fmt-' + curReq.apiFormat">{{ curReq.apiFormat.toUpperCase() }}</b></span>
|
||||
<span class="sbadge sm-badge"><span class="sm-l">消息数</span><b>{{ curReq.messageCount }}</b></span>
|
||||
<span class="sbadge sm-badge"><span class="sm-l">响应</span><b>{{ fmtN(curReq.responseChars) }}</b>chars</span>
|
||||
<span v-if="curReq.inputTokens" class="sbadge sm-badge"><span class="sm-l">↑ Cursor tokens</span><b>{{ fmtN(curReq.inputTokens) }}</b></span>
|
||||
<span v-if="curReq.outputTokens" class="sbadge sm-badge"><span class="sm-l">↓ Cursor tokens</span><b>{{ fmtN(curReq.outputTokens) }}</b></span>
|
||||
<!-- <span v-if="curReq.toolCount > 0" class="sbadge sm-badge"><span class="sm-l">工具定义</span><b>{{ curReq.toolCount }}</b>个</span> -->
|
||||
<span v-if="curReq.toolCallsDetected > 0" class="sbadge sm-badge"><span class="sm-l">工具调用</span><b>{{ curReq.toolCallsDetected }}</b>次</span>
|
||||
<span v-if="curReq.thinkingChars > 0" class="sbadge sm-badge"><span class="sm-l">Thinking</span><b>{{ fmtN(curReq.thinkingChars) }}</b>chars</span>
|
||||
|
||||
@@ -47,10 +47,12 @@
|
||||
<div class="conv-grid">
|
||||
<div class="cg-item"><span class="cg-l">原始工具数</span><span class="cg-v">{{ convSummary.origToolCount }}</span></div>
|
||||
<div class="cg-item"><span class="cg-l">Cursor工具数</span><span class="cg-v" style="color:var(--green)">0 <small>(嵌入消息)</small></span></div>
|
||||
<div class="cg-item"><span class="cg-l">工具指令占用</span><span class="cg-v">{{ convSummary.toolInstrChars > 0 ? fmtN(convSummary.toolInstrChars) + ' chars' : convSummary.origToolCount > 0 ? '嵌入#1' : 'N/A' }}</span></div>
|
||||
<div class="cg-item"><span class="cg-l">总上下文</span><span class="cg-v">{{ convSummary.totalChars ? fmtN(convSummary.totalChars) + ' chars' : '—' }}</span></div>
|
||||
<div class="cg-item"><span class="cg-l">↑ Cursor 输入 tokens</span><span class="cg-v" style="color:var(--blue)">{{ curReq?.inputTokens ? fmtN(curReq.inputTokens) : '—' }}</span></div>
|
||||
<div class="cg-item"><span class="cg-l">原始消息数</span><span class="cg-v">{{ convSummary.origMsgCount }}</span></div>
|
||||
<div class="cg-item"><span class="cg-l">Cursor消息数</span><span class="cg-v" style="color:var(--green)">{{ convSummary.cursorMsgCount }}</span></div>
|
||||
<div class="cg-item"><span class="cg-l">总上下文</span><span class="cg-v">{{ convSummary.totalChars ? fmtN(convSummary.totalChars) + ' chars' : '—' }}</span></div>
|
||||
<div class="cg-item"><span class="cg-l">工具指令占用</span><span class="cg-v">{{ convSummary.toolInstrChars > 0 ? fmtN(convSummary.toolInstrChars) + ' chars' : convSummary.origToolCount > 0 ? '嵌入#1' : 'N/A' }}</span></div>
|
||||
<div class="cg-item"><span class="cg-l">↓ Cursor 输出 tokens</span><span class="cg-v" style="color:var(--green)">{{ curReq?.outputTokens ? fmtN(curReq.outputTokens) : '—' }}</span></div>
|
||||
</div>
|
||||
<div v-if="convSummary.origToolCount > 0" class="tool-warn">
|
||||
⚠️ Cursor API 不支持原生 tools。{{ convSummary.origToolCount }} 个工具已转为文本指令嵌入 user#1{{ convSummary.toolInstrChars > 0 ? '(约 ' + fmtN(convSummary.toolInstrChars) + ' chars)' : '' }}
|
||||
@@ -638,7 +640,7 @@ mark.hl {
|
||||
|
||||
/* 转换摘要 */
|
||||
.conv-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 8px;
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.cg-item {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
<span class="rid">{{ req.requestId.slice(0, 8) }}</span>
|
||||
<span class="rfmt" :class="req.apiFormat">{{ req.apiFormat }}</span>
|
||||
<span v-if="req.responseChars" class="rchars">{{ fmtN(req.responseChars) }} chars</span>
|
||||
<span v-if="req.inputTokens" class="rchars">↑{{ fmtN(req.inputTokens) }}↓{{ fmtN(req.outputTokens ?? 0) }} tok</span>
|
||||
</div>
|
||||
<div class="rbd">
|
||||
<span v-if="req.stream" class="bg bg-stream">Stream</span>
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface RequestSummary {
|
||||
phaseTimings: PhaseTiming[];
|
||||
thinkingChars: number;
|
||||
systemPromptLength: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -66,6 +68,7 @@ export interface HotConfig {
|
||||
timeout: number;
|
||||
max_auto_continue: number;
|
||||
max_history_messages: number;
|
||||
max_history_tokens: number;
|
||||
thinking: { enabled: boolean } | null;
|
||||
compression: { enabled: boolean; level: 1 | 2 | 3; keep_recent: number; early_msg_max_chars: number };
|
||||
tools: { schema_mode: 'compact' | 'full' | 'names_only'; description_max_length: number; passthrough?: boolean; disabled?: boolean };
|
||||
|
||||
Reference in New Issue
Block a user