From a153dad4dedf7a1cc5ddef07c314d10b665250fa Mon Sep 17 00:00:00 2001 From: huangzhenting Date: Sat, 21 Mar 2026 18:42:12 +0800 Subject: [PATCH] feat: record and display real Cursor API token usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/logs.js | 10 +++++-- src/handler.ts | 41 ++++++++++++++++++++------- src/logger.ts | 2 ++ vue-ui/src/components/DetailPanel.vue | 2 ++ vue-ui/src/components/PayloadView.vue | 8 ++++-- vue-ui/src/components/RequestList.vue | 1 + 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/public/logs.js b/public/logs.js index 1c2d894..8006e16 100644 --- a/public/logs.js +++ b/public/logs.js @@ -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=[['状态',''+s.status.toUpperCase()+''],['耗时',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(['错误',''+escH(s.error)+'']); document.getElementById('sgrid').innerHTML=items.map(([l,v])=>'
'+l+''+v+'
').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+='
🔄 转换摘要
'; - h+='
'; + 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/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/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