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:
@@ -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(
|
||||
|
||||
@@ -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}}明细',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user