feat(ai): display cache hit/miss token usage in chat status bar

Extend TokenUsage types across the full data pipeline to include
cacheReadTokens and cacheWriteTokens. The ChatStatusBar tooltip now
shows cache hit information when available. This is a generic
implementation that works with any API provider reporting cache metrics,
not limited to DeepSeek.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-06-10 22:18:20 +08:00
1 parent 2b6aed52c9
commit 39700b95b7
17 files changed
+104 -20

No files matched your search

+8 -8
View File
@@ -156,7 +156,7 @@ describe('runChatTurn', () => {
onEvent({
type: 'done',
isFinished: true,
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 },
})
},
}
@@ -174,7 +174,7 @@ describe('runChatTurn', () => {
aiChatId: 'ai_chat_1',
role: 'assistant',
content: 'hi',
tokenUsage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
tokenUsage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 },
},
])
})
@@ -222,7 +222,7 @@ describe('runChatTurn', () => {
onEvent({
type: 'done',
isFinished: true,
usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 },
usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 },
})
},
}
@@ -248,7 +248,7 @@ describe('runChatTurn', () => {
role: 'assistant',
content: '年度趋势如下。',
contentBlocks: result.contentBlocks,
tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 },
tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 },
},
])
})
@@ -316,7 +316,7 @@ describe('runChatTurn', () => {
onEvent({
type: 'done',
isFinished: true,
usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 },
usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 },
})
},
}
@@ -347,7 +347,7 @@ describe('runChatTurn', () => {
role: 'assistant',
content: '年度趋势如下。',
contentBlocks: result.contentBlocks,
tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 },
tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 },
},
])
})
@@ -411,7 +411,7 @@ describe('runChatTurn', () => {
onEvent({
type: 'done',
isFinished: true,
usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 },
usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 },
})
},
}
@@ -429,7 +429,7 @@ describe('runChatTurn', () => {
role: 'assistant',
content: '2026-Q1 是峰值。',
contentBlocks: result.contentBlocks,
tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8 },
tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 },
})
})
+1 -1
View File
@@ -125,7 +125,7 @@ export function createElectronRunAgentStream(): (
round: 0,
toolsUsed: 0,
contextTokens: 0,
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
updatedAt: Date.now(),
} satisfies AgentRuntimeStatus,
})
+2
View File
@@ -28,6 +28,8 @@ export interface TokenUsage {
promptTokens: number
completionTokens: number
totalTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
export interface AgentRuntimeStatus {
@@ -50,7 +50,13 @@ export function registerAiChatRoutes(server: FastifyInstance, ctx: HttpRouteCont
dataKeywords?: string[]
dataMessageCount?: number
contentBlocks?: unknown[]
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number }
tokenUsage?: {
promptTokens: number
completionTokens: number
totalTokens: number
cacheReadTokens?: number
cacheWriteTokens?: number
}
}
}>('/_web/ai/chats/:id/messages', async (request) => {
const { role, content, dataKeywords, dataMessageCount, contentBlocks, tokenUsage } = request.body
@@ -121,7 +127,13 @@ export function registerAiChatRoutes(server: FastifyInstance, ctx: HttpRouteCont
role: 'user' | 'assistant' | 'summary'
content: string
contentBlocks?: unknown[]
tokenUsage?: { promptTokens: number; completionTokens: number; totalTokens: number }
tokenUsage?: {
promptTokens: number
completionTokens: number
totalTokens: number
cacheReadTokens?: number
cacheWriteTokens?: number
}
}
}>('/_web/ai/chats/:id/messages/insert-after', async (request, reply) => {
const { afterMessageId, role, content, contentBlocks, tokenUsage } = request.body
@@ -64,15 +64,29 @@ describe('AgentEventHandler', () => {
})
handler.handleCoreEvent(
{ type: 'usage_update', usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 } },
{
type: 'usage_update',
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150, cacheReadTokens: 30, cacheWriteTokens: 10 },
},
[]
)
const usage = handler.cloneUsage()
assert.equal(usage.totalTokens, 150)
assert.equal(usage.cacheReadTokens, 30)
assert.equal(usage.cacheWriteTokens, 10)
handler.handleCoreEvent(
{ type: 'usage_update', usage: { promptTokens: 200, completionTokens: 100, totalTokens: 300 } },
{
type: 'usage_update',
usage: {
promptTokens: 200,
completionTokens: 100,
totalTokens: 300,
cacheReadTokens: 60,
cacheWriteTokens: 20,
},
},
[]
)
assert.equal(usage.totalTokens, 150, 'clone should be independent')
+9 -1
View File
@@ -45,7 +45,13 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
const resolvedStreamFn = (options.streamFn ?? defaultStreamSimple) as typeof defaultStreamSimple
const totalUsage: AgentTokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
const totalUsage: AgentTokenUsage = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
const toolsUsed: string[] = []
let toolRounds = 0
@@ -54,6 +60,8 @@ export async function runAgentCore(options: AgentCoreOptions): Promise<AgentCore
totalUsage.promptTokens += usage.input || 0
totalUsage.completionTokens += usage.output || 0
totalUsage.totalTokens += usage.totalTokens || usage.input + usage.output || 0
totalUsage.cacheReadTokens += usage.cacheRead || 0
totalUsage.cacheWriteTokens += usage.cacheWrite || 0
}
if (abortSignal?.aborted) {
@@ -16,6 +16,8 @@ export interface TokenUsage {
promptTokens: number
completionTokens: number
totalTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
export interface AgentRuntimeStatus {
@@ -143,7 +145,13 @@ export class AgentEventHandler {
readonly toolsUsed: string[] = []
toolRounds: number = 0
private totalUsage: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
private totalUsage: TokenUsage = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
private lastStatusAt = 0
private readonly onChunk: (chunk: AgentStreamChunk) => void
private readonly context: EventHandlerContext
@@ -205,6 +213,8 @@ export class AgentEventHandler {
promptTokens: this.totalUsage.promptTokens,
completionTokens: this.totalUsage.completionTokens,
totalTokens: this.totalUsage.totalTokens,
cacheReadTokens: this.totalUsage.cacheReadTokens,
cacheWriteTokens: this.totalUsage.cacheWriteTokens,
}
}
@@ -14,6 +14,8 @@ export interface AgentTokenUsage {
promptTokens: number
completionTokens: number
totalTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
export interface SimpleHistoryMessage {
+11 -1
View File
@@ -59,6 +59,8 @@ export interface TokenUsageData {
promptTokens: number
completionTokens: number
totalTokens: number
cacheReadTokens?: number
cacheWriteTokens?: number
}
export interface AIMessage {
@@ -831,13 +833,21 @@ export class AIChatManager {
getAIChatTokenUsage(aiChatId: string): TokenUsageData {
const rows = this.getActivePathRows(aiChatId)
const result: TokenUsageData = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
const result: TokenUsageData = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
for (const row of rows) {
if (row.tokenUsage) {
const usage = JSON.parse(row.tokenUsage) as TokenUsageData
result.promptTokens += usage.promptTokens
result.completionTokens += usage.completionTokens
result.totalTokens += usage.totalTokens
result.cacheReadTokens! += usage.cacheReadTokens || 0
result.cacheWriteTokens! += usage.cacheWriteTokens || 0
}
}
return result
+7 -1
View File
@@ -18,7 +18,7 @@ const layoutStore = useLayoutStore()
// Props
const props = defineProps<{
sessionTokenUsage: { totalTokens: number }
sessionTokenUsage: { totalTokens: number; cacheReadTokens: number; cacheWriteTokens: number }
agentStatus?: AgentRuntimeStatus | null
currentAIChatId?: string | null
estimatedContextTokens?: number
@@ -80,6 +80,9 @@ function formatCompactNumber(value: number): string {
const totalTokenUsageText = computed(() => formatNumber(props.sessionTokenUsage.totalTokens))
const totalTokenUsageCompactText = computed(() => formatCompactNumber(props.sessionTokenUsage.totalTokens))
const hasCacheData = computed(() => props.sessionTokenUsage.cacheReadTokens > 0)
const cacheReadText = computed(() => formatCompactNumber(props.sessionTokenUsage.cacheReadTokens))
const contextTokens = computed(() => {
if (props.agentStatus?.contextTokens) return props.agentStatus.contextTokens
if (props.estimatedContextTokens && props.estimatedContextTokens > 0) return props.estimatedContextTokens
@@ -338,6 +341,9 @@ const thinkingLevelLabel = computed(() => {
{{ formatCompactNumber(modelContextWindow) }}
</div>
<div>{{ t('ai.chat.statusBar.tokenUsageTitle') }}: {{ totalTokenUsageCompactText }}</div>
<div v-if="hasCacheData">
{{ t('ai.chat.statusBar.cacheHit') }}: {{ cacheReadText }}
</div>
</div>
</template>
</UTooltip>
+1
View File
@@ -215,6 +215,7 @@
}
},
"tokenUsageTitle": "Total token usage",
"cacheHit": "Cache hit",
"export": {
"label": "Export",
"title": "Export current conversation"
+1
View File
@@ -215,6 +215,7 @@
}
},
"tokenUsageTitle": "累計 Token 使用量",
"cacheHit": "キャッシュヒット",
"export": {
"label": "エクスポート",
"title": "現在のチャットをエクスポート"
+1
View File
@@ -215,6 +215,7 @@
}
},
"tokenUsageTitle": "累计 Token 使用量",
"cacheHit": "缓存命中",
"export": {
"label": "导出",
"title": "导出当前对话"
+1
View File
@@ -215,6 +215,7 @@
}
},
"tokenUsageTitle": "累計 Token 用量",
"cacheHit": "快取命中",
"export": {
"label": "匯出",
"title": "匯出當前對話"
+2
View File
@@ -66,6 +66,8 @@ export interface TokenUsage {
promptTokens: number
completionTokens: number
totalTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
export interface AgentRuntimeStatus {
+2
View File
@@ -36,6 +36,8 @@ export interface TokenUsageData {
promptTokens: number
completionTokens: number
totalTokens: number
cacheReadTokens?: number
cacheWriteTokens?: number
}
export interface AIMessage {
+15 -3
View File
@@ -211,7 +211,17 @@ export function buildAIChatKey(params: {
}
function createEmptyTokenUsage(): TokenUsage {
return { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
return { promptTokens: 0, completionTokens: 0, totalTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
}
function toTokenUsage(data: { [K in keyof TokenUsage]?: number }): TokenUsage {
return {
promptTokens: data.promptTokens ?? 0,
completionTokens: data.completionTokens ?? 0,
totalTokens: data.totalTokens ?? 0,
cacheReadTokens: data.cacheReadTokens ?? 0,
cacheWriteTokens: data.cacheWriteTokens ?? 0,
}
}
function normalizeSerializedError(error: unknown): SerializedErrorInfo {
@@ -523,7 +533,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
buffer.messages.splice(0, buffer.messages.length, ...history.map((msg) => toRuntimeMessage(msg)))
buffer.sourceMessages.splice(0, buffer.sourceMessages.length)
buffer.currentKeywords.splice(0, buffer.currentKeywords.length)
buffer.sessionTokenUsage = tokenUsage
buffer.sessionTokenUsage = toTokenUsage(tokenUsage)
buffer.loaded = true
}
@@ -1083,6 +1093,8 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
promptTokens: state.sessionTokenUsage.promptTokens + chunk.usage.promptTokens,
completionTokens: state.sessionTokenUsage.completionTokens + chunk.usage.completionTokens,
totalTokens: state.sessionTokenUsage.totalTokens + chunk.usage.totalTokens,
cacheReadTokens: state.sessionTokenUsage.cacheReadTokens + chunk.usage.cacheReadTokens,
cacheWriteTokens: state.sessionTokenUsage.cacheWriteTokens + chunk.usage.cacheWriteTokens,
}
}
setAgentPhase(state, 'completed', chunk.usage ? { totalUsage: chunk.usage } : undefined)
@@ -1708,7 +1720,7 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
}
}
targetBuffer.sessionTokenUsage = await useAIService().getAIChatTokenUsage(state.currentAIChatId!)
targetBuffer.sessionTokenUsage = toTokenUsage(await useAIService().getAIChatTokenUsage(state.currentAIChatId!))
state.sessionTokenUsage = { ...targetBuffer.sessionTokenUsage }
return { success: true }
} catch (error) {