From 36b38ce2a83e3ed69b707e76335677e91e69d123 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 15 Aug 2026 22:01:49 +0800 Subject: [PATCH] feat: show normalized prompt cache hit rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token usage previously showed cache reads and writes without a comparable hit rate. Activity usage now calculates a weighted cache hit rate using OpenAI-compatible input totals and Anthropic's separately reported input, cache-read, and cache-write tokens, then shows it in totals and grouped details. Release note: Token 用量统计新增缓存命中率,并针对 OpenAI 兼容接口与 Anthropic Messages 的不同上报口径进行归一化计算。 --- FEATURES.md | 2 +- src/main/assistant/assistant-database.test.ts | 5 +++ src/main/assistant/assistant-database.ts | 27 +++++++++++- src/renderer/src/ActivityPanel.test.tsx | 3 +- src/renderer/src/ActivityPanel.tsx | 24 +++++++++- .../src/i18n/locales/en-US/activity.ts | 1 + .../src/i18n/locales/zh-CN/activity.ts | 1 + src/renderer/src/token-usage.test.ts | 38 ++++++++++++++++ src/renderer/src/token-usage.ts | 44 +++++++++++++++++-- src/shared/assistant-contracts.ts | 2 + 10 files changed, 138 insertions(+), 9 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 4f145ae..24583ce 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -46,7 +46,7 @@ ### 工作管理、长期协作与工作流 -- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;活动按会话分组并默认收起,避免长历史占满页面。 +- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;Token 用量按 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 - [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。 - [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 68208bf..8595b1e 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -2175,6 +2175,7 @@ describe('AssistantDatabase', () => { output: 25, cacheRead: 40, cacheWrite: 12, + cacheInput: 177, totalTokens: 150 }, records: [ @@ -2185,6 +2186,7 @@ describe('AssistantDatabase', () => { output: 25, cacheRead: 40, cacheWrite: 12, + cacheInput: 177, totalTokens: 150 }) ] @@ -2334,6 +2336,7 @@ describe('AssistantDatabase', () => { output: 105, cacheRead: 42, cacheWrite: 15, + cacheInput: 312, totalTokens: 360 }) expect(summary.records).toHaveLength(3) @@ -2353,6 +2356,7 @@ describe('AssistantDatabase', () => { output: 60, cacheRead: 35, cacheWrite: 12, + cacheInput: 197, totalTokens: 210 }), expect.objectContaining({ @@ -2684,6 +2688,7 @@ describe('AssistantDatabase', () => { output: 0, cacheRead: 0, cacheWrite: 0, + cacheInput: 0, totalTokens: 0 }, records: [] diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index b80cc75..6252a8e 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -317,6 +317,7 @@ type TokenUsageRecordRow = { output_tokens: number cache_read_tokens: number cache_write_tokens: number + cache_input_tokens: number } type ComputerControlActionRow = { @@ -2799,7 +2800,15 @@ export class AssistantDatabase { SUM(usage.input_tokens) AS input_tokens, SUM(usage.output_tokens) AS output_tokens, SUM(usage.cache_read_tokens) AS cache_read_tokens, - SUM(usage.cache_write_tokens) AS cache_write_tokens + SUM(usage.cache_write_tokens) AS cache_write_tokens, + SUM( + usage.input_tokens + + CASE + WHEN LOWER(usage.provider) LIKE '%anthropic%' + THEN usage.cache_read_tokens + usage.cache_write_tokens + ELSE 0 + END + ) AS cache_input_tokens FROM model_usage_calls usage JOIN tasks ON tasks.id = usage.request_id LEFT JOIN projects ON projects.id = tasks.project_id @@ -2832,6 +2841,7 @@ export class AssistantDatabase { output: row.output_tokens, cacheRead: row.cache_read_tokens, cacheWrite: row.cache_write_tokens, + cacheInput: row.cache_input_tokens, totalTokens: row.input_tokens + row.output_tokens })) const totalRow = this.requireDatabase() @@ -2841,7 +2851,18 @@ export class AssistantDatabase { COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, - COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COALESCE( + SUM( + input_tokens + + CASE + WHEN LOWER(provider) LIKE '%anthropic%' + THEN cache_read_tokens + cache_write_tokens + ELSE 0 + END + ), + 0 + ) AS cache_input_tokens FROM model_usage_calls` ) .get() as { @@ -2850,6 +2871,7 @@ export class AssistantDatabase { output_tokens: number cache_read_tokens: number cache_write_tokens: number + cache_input_tokens: number } const totals = { callCount: totalRow.call_count, @@ -2857,6 +2879,7 @@ export class AssistantDatabase { output: totalRow.output_tokens, cacheRead: totalRow.cache_read_tokens, cacheWrite: totalRow.cache_write_tokens, + cacheInput: totalRow.cache_input_tokens, totalTokens: totalRow.input_tokens + totalRow.output_tokens } return { totals, records } diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index f30cc42..eacae6f 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -521,9 +521,10 @@ describe('ActivityPanel', () => { const stats = screen.getByLabelText('Token 用量统计') expect(within(stats).getByText('150')).toBeInTheDocument() + expect(within(stats).getByText('32%')).toBeInTheDocument() const projectRow = screen.getByRole('row', { - name: '项目甲gpt-5 · openai 100 20 10 40 120' + name: '项目甲gpt-5 · openai 100 20 10 40 40% 120' }) expect(projectRow).toBeInTheDocument() expect( diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index d0b28c0..aaad481 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -263,6 +263,16 @@ export function ActivityPanel({ () => new Intl.NumberFormat(i18n.resolvedLanguage || 'zh-CN'), [i18n.resolvedLanguage] ) + const tokenPercentFormatter = useMemo( + () => + new Intl.NumberFormat(i18n.resolvedLanguage || 'zh-CN', { + style: 'percent', + maximumFractionDigits: 1 + }), + [i18n.resolvedLanguage] + ) + const formatCacheHitRate = (value: number | undefined): string => + value === undefined ? '—' : tokenPercentFormatter.format(value) const formatCount = (value: number): string => tokenCountFormatter.format(value) const statusLabels: Record = { @@ -949,6 +959,12 @@ export function ActivityPanel({ )} +
+
{t('tokenUsage.columns.cacheHitRate')}
+
+ {formatCacheHitRate(tokenTotals.cacheHitRate)} +
+
{t('tokenUsage.columns.total')}
@@ -974,6 +990,9 @@ export function ActivityPanel({ {t('tokenUsage.columns.cacheRead')} + + {t('tokenUsage.columns.cacheHitRate')} + {t('tokenUsage.columns.total')} @@ -982,7 +1001,7 @@ export function ActivityPanel({ {tokenRows.length === 0 ? ( - + {t('tokenUsage.empty')} @@ -1017,6 +1036,9 @@ export function ActivityPanel({ row.cacheReadTokens )} + + {formatCacheHitRate(row.cacheHitRate)} + {tokenCountFormatter.format( row.totalTokens diff --git a/src/renderer/src/i18n/locales/en-US/activity.ts b/src/renderer/src/i18n/locales/en-US/activity.ts index b7a03c9..1f1fc6c 100644 --- a/src/renderer/src/i18n/locales/en-US/activity.ts +++ b/src/renderer/src/i18n/locales/en-US/activity.ts @@ -61,6 +61,7 @@ export const activity = { output: 'Output', cacheWrite: 'Cache writes', cacheRead: 'Cache reads', + cacheHitRate: 'Cache hit rate', total: 'Total' }, detailAriaLabel: 'Token usage details by {{group}}', diff --git a/src/renderer/src/i18n/locales/zh-CN/activity.ts b/src/renderer/src/i18n/locales/zh-CN/activity.ts index 1b2d9c0..a6fbbd5 100644 --- a/src/renderer/src/i18n/locales/zh-CN/activity.ts +++ b/src/renderer/src/i18n/locales/zh-CN/activity.ts @@ -58,6 +58,7 @@ export const activity = { output: '输出', cacheWrite: '缓存写入', cacheRead: '缓存读取', + cacheHitRate: '缓存命中率', total: '总计' }, detailAriaLabel: 'Token 用量{{group}}明细', diff --git a/src/renderer/src/token-usage.test.ts b/src/renderer/src/token-usage.test.ts index 81dc334..04e703f 100644 --- a/src/renderer/src/token-usage.test.ts +++ b/src/renderer/src/token-usage.test.ts @@ -61,6 +61,8 @@ describe('token usage aggregation', () => { outputTokens: 23, cacheReadTokens: 40, cacheWriteTokens: 10, + cacheInputTokens: 112, + cacheHitRate: 40 / 112, totalTokens: 135 }) expect(groupTokenUsage(usage, 'project')).toEqual([ @@ -72,6 +74,8 @@ describe('token usage aggregation', () => { outputTokens: 23, cacheReadTokens: 40, cacheWriteTokens: 10, + cacheInputTokens: 112, + cacheHitRate: 40 / 112, totalTokens: 135 } ]) @@ -85,6 +89,40 @@ describe('token usage aggregation', () => { ]) }) + it('normalizes cache hit rate for OpenAI and Anthropic input usage', () => { + const usage = makeTokenUsage() + usage.records = [ + { + ...usage.records[0]!, + provider: 'openai', + input: 100, + cacheRead: 40, + cacheWrite: 10 + }, + { + ...usage.records[1]!, + provider: 'goodbuddy-anthropic', + model: 'claude-sonnet', + input: 50, + cacheRead: 30, + cacheWrite: 20 + } + ] + + const rows = groupTokenUsage(usage, 'model') + + expect(rows.find((row) => row.label === 'gpt-5')).toMatchObject({ + cacheInputTokens: 100, + cacheHitRate: 0.4 + }) + expect( + rows.find((row) => row.label === 'claude-sonnet') + ).toMatchObject({ + cacheInputTokens: 100, + cacheHitRate: 0.3 + }) + }) + it('uses fallback labels when grouping metadata is unavailable', () => { const usage = makeTokenUsage() usage.records = [ diff --git a/src/renderer/src/token-usage.ts b/src/renderer/src/token-usage.ts index d2d6f15..74d7cc4 100644 --- a/src/renderer/src/token-usage.ts +++ b/src/renderer/src/token-usage.ts @@ -7,6 +7,8 @@ export type TokenUsageTotals = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + cacheInputTokens: number + cacheHitRate?: number totalTokens: number } @@ -18,7 +20,17 @@ export type TokenUsageGroupRow = TokenUsageTotals & { type TokenUsageRecord = TokenUsageSummary['records'][number] -function usageNumbers(source: unknown): TokenUsageTotals { +function usesSeparatedAnthropicInput(provider: unknown): boolean { + return ( + typeof provider === 'string' && + provider.toLocaleLowerCase().includes('anthropic') + ) +} + +function usageNumbers( + source: unknown, + provider?: unknown +): TokenUsageTotals { const values = source as Record const read = (preferred: string, legacy: string): number => { const value = values[preferred] ?? values[legacy] @@ -28,12 +40,28 @@ function usageNumbers(source: unknown): TokenUsageTotals { } const inputTokens = read('inputTokens', 'input') const outputTokens = read('outputTokens', 'output') + const cacheReadTokens = read('cacheReadTokens', 'cacheRead') + const cacheWriteTokens = read('cacheWriteTokens', 'cacheWrite') + const reportedCacheInput = values.cacheInputTokens ?? values.cacheInput + const cacheInputTokens = + typeof reportedCacheInput === 'number' && + Number.isFinite(reportedCacheInput) + ? reportedCacheInput + : inputTokens + + (usesSeparatedAnthropicInput(provider) + ? cacheReadTokens + cacheWriteTokens + : 0) return { inputTokens, outputTokens, - cacheReadTokens: read('cacheReadTokens', 'cacheRead'), - cacheWriteTokens: read('cacheWriteTokens', 'cacheWrite'), + cacheReadTokens, + cacheWriteTokens, + cacheInputTokens, + cacheHitRate: + cacheInputTokens > 0 + ? Math.min(cacheReadTokens / cacheInputTokens, 1) + : undefined, totalTokens: inputTokens + outputTokens } } @@ -95,7 +123,7 @@ export function groupTokenUsage( for (const record of tokenUsage.records) { const identity = groupIdentity(record, group) - const usage = usageNumbers(record) + const usage = usageNumbers(record, record.provider) const existing = rows.get(identity.key) if (existing) { @@ -103,6 +131,14 @@ export function groupTokenUsage( existing.outputTokens += usage.outputTokens existing.cacheReadTokens += usage.cacheReadTokens existing.cacheWriteTokens += usage.cacheWriteTokens + existing.cacheInputTokens += usage.cacheInputTokens + existing.cacheHitRate = + existing.cacheInputTokens > 0 + ? Math.min( + existing.cacheReadTokens / existing.cacheInputTokens, + 1 + ) + : undefined existing.totalTokens = existing.inputTokens + existing.outputTokens if (!existing.label && identity.label) { diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 4fbe241..6db8ecb 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -379,6 +379,7 @@ export type TokenUsageRecord = { output: number cacheRead: number cacheWrite: number + cacheInput?: number totalTokens: number } @@ -389,6 +390,7 @@ export type TokenUsageSummary = { output: number cacheRead: number cacheWrite: number + cacheInput?: number totalTokens: number } records: TokenUsageRecord[]