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