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:
+1
-1
@@ -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: []
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user