feat: show normalized prompt cache hit rates

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 的不同上报口径进行归一化计算。
This commit is contained in:
mesalogo
2026-08-15 22:01:49 +08:00
parent 0b96400e51
commit 36b38ce2a8
10 changed files with 138 additions and 9 deletions
+1 -1
View File
@@ -46,7 +46,7 @@
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;Token 用量按 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
@@ -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: []
+25 -2
View File
@@ -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 }
+2 -1
View File
@@ -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(
+23 -1
View File
@@ -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<ActivityRecord['status'], string> = {
@@ -949,6 +959,12 @@ export function ActivityPanel({
)}
</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.cacheHitRate')}</dt>
<dd>
{formatCacheHitRate(tokenTotals.cacheHitRate)}
</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.total')}</dt>
<dd>
@@ -974,6 +990,9 @@ export function ActivityPanel({
<th scope="col">
{t('tokenUsage.columns.cacheRead')}
</th>
<th scope="col">
{t('tokenUsage.columns.cacheHitRate')}
</th>
<th scope="col">
{t('tokenUsage.columns.total')}
</th>
@@ -982,7 +1001,7 @@ export function ActivityPanel({
<tbody>
{tokenRows.length === 0 ? (
<tr>
<td className="token-usage__empty" colSpan={6}>
<td className="token-usage__empty" colSpan={7}>
{t('tokenUsage.empty')}
</td>
</tr>
@@ -1017,6 +1036,9 @@ export function ActivityPanel({
row.cacheReadTokens
)}
</td>
<td>
{formatCacheHitRate(row.cacheHitRate)}
</td>
<td>
{tokenCountFormatter.format(
row.totalTokens
@@ -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}}',
@@ -58,6 +58,7 @@ export const activity = {
output: '输出',
cacheWrite: '缓存写入',
cacheRead: '缓存读取',
cacheHitRate: '缓存命中率',
total: '总计'
},
detailAriaLabel: 'Token 用量{{group}}明细',
+38
View File
@@ -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 = [
+40 -4
View File
@@ -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<string, unknown>
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) {
+2
View File
@@ -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[]