';
+ h+='
';
h+='
原始工具数'+origToolCount+'
';
h+='
Cursor 工具数0 (嵌入消息)
';
- h+='
工具指令占用'+(toolInstructionChars>0?fmtN(toolInstructionChars)+' chars':origToolCount>0?'嵌入第1条消息':'N/A')+'
';
+ h+='
总上下文'+(cursorTotalChars>0?fmtN(cursorTotalChars)+' chars':'—')+'
';
+ h+='
↑ Cursor 输入 tokens'+(s.inputTokens?fmtN(s.inputTokens):'—')+'
';
h+='
原始消息数'+origMsgCount+'
';
h+='
Cursor 消息数'+cursorMsgCount+'
';
- h+='
总上下文大小'+(cursorTotalChars>0?fmtN(cursorTotalChars)+' chars':'—')+'
';
+ h+='
工具指令占用'+(toolInstructionChars>0?fmtN(toolInstructionChars)+' chars':origToolCount>0?'嵌入第1条消息':'N/A')+'
';
+ h+='
↓ Cursor 输出 tokens'+(s.outputTokens?fmtN(s.outputTokens):'—')+'
';
h+='
';
if(origToolCount>0){
h+='
⚠️ Cursor API 不支持原生 tools 参数。'+origToolCount+' 个工具定义已转换为文本指令,嵌入在 user #1 消息中'+(toolInstructionChars>0?'(约 '+fmtN(toolInstructionChars)+' chars)':'')+'
';
diff --git a/src/config-api.ts b/src/config-api.ts
index 081fbdc..7abfaf3 100644
--- a/src/config-api.ts
+++ b/src/config-api.ts
@@ -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);
diff --git a/src/config.ts b/src/config.ts
index 8877e68..1cffc10 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -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(',');
diff --git a/src/converter.ts b/src/converter.ts
index 7eb478f..c18196f 100644
--- a/src/converter.ts
+++ b/src/converter.ts
@@ -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 条消息完整,对早期消息进行结构感知压缩
diff --git a/src/handler.ts b/src/handler.ts
index 58a21a1..da4562d 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -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) {
diff --git a/src/logger.ts b/src/logger.ts
index 18f1aad..a794c69 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -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;
}
diff --git a/src/tokenizer.ts b/src/tokenizer.ts
new file mode 100644
index 0000000..3c7f772
--- /dev/null
+++ b/src/tokenizer.ts
@@ -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;
+}
diff --git a/src/types.ts b/src/types.ts
index de0a49b..39f7030 100644
--- a/src/types.ts
+++ b/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';
diff --git a/vue-ui/README.md b/vue-ui/README.md
index 0ce502d..9a4c6cc 100644
--- a/vue-ui/README.md
+++ b/vue-ui/README.md
@@ -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.*` | 压缩开关、级别、保留条数等 |
diff --git a/vue-ui/src/components/ConfigDrawer.vue b/vue-ui/src/components/ConfigDrawer.vue
index 4611b6e..b72d5d7 100644
--- a/vue-ui/src/components/ConfigDrawer.vue
+++ b/vue-ui/src/components/ConfigDrawer.vue
@@ -25,9 +25,12 @@
-
+
+
+
+
diff --git a/vue-ui/src/components/DetailPanel.vue b/vue-ui/src/components/DetailPanel.vue
index 9245185..fed01c9 100644
--- a/vue-ui/src/components/DetailPanel.vue
+++ b/vue-ui/src/components/DetailPanel.vue
@@ -21,6 +21,8 @@
格式{{ curReq.apiFormat.toUpperCase() }}
消息数{{ curReq.messageCount }}
响应{{ fmtN(curReq.responseChars) }}chars
+ ↑ Cursor tokens{{ fmtN(curReq.inputTokens) }}
+ ↓ Cursor tokens{{ fmtN(curReq.outputTokens) }}
工具调用{{ curReq.toolCallsDetected }}次
Thinking{{ fmtN(curReq.thinkingChars) }}chars
diff --git a/vue-ui/src/components/PayloadView.vue b/vue-ui/src/components/PayloadView.vue
index 36a344e..cc53433 100644
--- a/vue-ui/src/components/PayloadView.vue
+++ b/vue-ui/src/components/PayloadView.vue
@@ -47,10 +47,12 @@
原始工具数{{ convSummary.origToolCount }}
Cursor工具数0 (嵌入消息)
-
工具指令占用{{ convSummary.toolInstrChars > 0 ? fmtN(convSummary.toolInstrChars) + ' chars' : convSummary.origToolCount > 0 ? '嵌入#1' : 'N/A' }}
+
总上下文{{ convSummary.totalChars ? fmtN(convSummary.totalChars) + ' chars' : '—' }}
+
↑ Cursor 输入 tokens{{ curReq?.inputTokens ? fmtN(curReq.inputTokens) : '—' }}
原始消息数{{ convSummary.origMsgCount }}
Cursor消息数{{ convSummary.cursorMsgCount }}
-
总上下文{{ convSummary.totalChars ? fmtN(convSummary.totalChars) + ' chars' : '—' }}
+
工具指令占用{{ convSummary.toolInstrChars > 0 ? fmtN(convSummary.toolInstrChars) + ' chars' : convSummary.origToolCount > 0 ? '嵌入#1' : 'N/A' }}
+
↓ Cursor 输出 tokens{{ curReq?.outputTokens ? fmtN(curReq.outputTokens) : '—' }}
⚠️ 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;
diff --git a/vue-ui/src/components/RequestList.vue b/vue-ui/src/components/RequestList.vue
index e56bb7c..a521abf 100644
--- a/vue-ui/src/components/RequestList.vue
+++ b/vue-ui/src/components/RequestList.vue
@@ -60,6 +60,7 @@
{{ req.requestId.slice(0, 8) }}
{{ req.apiFormat }}
{{ fmtN(req.responseChars) }} chars
+ ↑{{ fmtN(req.inputTokens) }}↓{{ fmtN(req.outputTokens ?? 0) }} tok
Stream
diff --git a/vue-ui/src/types.ts b/vue-ui/src/types.ts
index 380a80d..195fd14 100644
--- a/vue-ui/src/types.ts
+++ b/vue-ui/src/types.ts
@@ -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 };