feat: record and display real Cursor API token usage

Capture the actual input/output token counts from Cursor API's finish
event (messageMetadata.usage) and use them in place of tiktoken
estimates where available. Fall back to tiktoken if not present.

- src/types.ts: extend CursorSSEEvent with finishReason/messageMetadata
- src/handler.ts: capture finish event usage in streaming paths, pass
  real counts to updateSummary with tiktoken fallback
- src/logger.ts: add inputTokens/outputTokens fields to RequestSummary
- vue-ui: show ↑/↓ Cursor tokens in RequestList, DetailPanel, PayloadView
- public/logs.js: show ↑/↓ Cursor tokens in scard and prompts summary
This commit is contained in:
huangzhenting
2026-03-21 18:42:12 +08:00
parent b542d554c6
commit a153dad4de
6 changed files with 48 additions and 16 deletions

View File

@@ -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>';

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>