fix: correct OpenCode cache hit rates

Include cache reads and writes in the complete input denominator for OpenCode usage. Use the same calculation for stored records, summary totals, and renderer fallbacks so existing history is corrected without migration.

Add regressions for full cache hits, cache writes, mixed Runtime aggregation, and persisted conversation/system usage. Focused validation passed 41 tests; repository-wide checks were blocked by concurrent work-mode changes.

Release note: 修复 OpenCode 缓存命中率偏高或完全命中时显示为空的问题,已有用量记录在重新查询时按完整输入量计算。
This commit is contained in:
mesalogo
2026-10-04 18:03:33 +08:00
parent 4d18794e74
commit 0cd5da33f4
6 changed files with 88 additions and 23 deletions
@@ -7395,6 +7395,49 @@ describe('AssistantDatabase', () => {
database.close()
})
it.each([false, true])('normalizes persisted OpenCode cache input in records and totals (system=%s)', async (system) => {
const database = await createDatabase()
try {
const taskId = randomUUID()
database.createTask({
id: taskId,
conversationId: system ? `knowledge:${randomUUID()}` : randomUUID(),
title: 'Cache usage',
instructions: 'Cache usage',
workMode: 'ask'
})
const calls = [
{ runtime: 'opencode', provider: 'goodbuddy-openai-chat', input: 60, cacheRead: 40, cacheWrite: 0 },
{ runtime: 'opencode', provider: 'goodbuddy-openai-responses', input: 10, cacheRead: 90, cacheWrite: 0 },
{ runtime: 'opencode', provider: 'custom', input: 0, cacheRead: 100, cacheWrite: 0 },
{ runtime: 'opencode', provider: 'goodbuddy-anthropic', input: 50, cacheRead: 30, cacheWrite: 20 },
{ runtime: 'model', provider: 'openai', input: 100, cacheRead: 40, cacheWrite: 0 },
{ runtime: 'model', provider: 'anthropic', input: 50, cacheRead: 30, cacheWrite: 20 }
]
for (const [index, call] of calls.entries()) {
database.upsertModelUsageCall({
requestId: taskId, callId: `call-${index}`, model: 'test-model',
output: 10, ...call
})
}
// Reopen persisted raw usage: existing records need no data migration.
database.close()
database.initialize('C:\\Workspace')
const summary = database.getTokenUsageSummary()
expect(summary.records).toHaveLength(6)
for (const record of summary.records) {
expect(record.cacheInput).toBe(100)
}
expect(summary.totals).toMatchObject({ callCount: 6, cacheRead: 330, cacheInput: 600 })
expect(system ? summary.systemTotals : summary.conversationTotals).toEqual(summary.totals)
expect(system ? summary.conversationTotals : summary.systemTotals).toMatchObject({
callCount: 0, cacheRead: 0, cacheInput: 0
})
} finally {
database.close()
}
})
it('persists global magic notes and AI comments without todo proposals', async () => {
const database = await createDatabase()
const globalNote = database.createMagicNote({
+9 -19
View File
@@ -5918,6 +5918,13 @@ export class AssistantDatabase {
}
getTokenUsageSummary(): TokenUsageSummary {
// OpenCode reports uncached input for every provider, including OpenAI.
const cacheInputSql = `usage.input_tokens + CASE
WHEN usage.runtime = 'opencode'
OR LOWER(usage.provider) LIKE '%anthropic%'
THEN usage.cache_read_tokens + usage.cache_write_tokens
ELSE 0
END`
const systemSourceSql = `CASE
WHEN tasks.conversation_id LIKE 'heartbeat:%' THEN 'heartbeat'
WHEN tasks.conversation_id LIKE 'supervision:%' THEN 'supervision'
@@ -5947,14 +5954,7 @@ export class AssistantDatabase {
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.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
SUM(${cacheInputSql}) 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
@@ -6017,17 +6017,7 @@ export class AssistantDatabase {
COALESCE(SUM(usage.output_tokens), 0) AS output_tokens,
COALESCE(SUM(usage.cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(usage.cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(
SUM(
usage.input_tokens +
CASE
WHEN LOWER(usage.provider) LIKE '%anthropic%'
THEN usage.cache_read_tokens + usage.cache_write_tokens
ELSE 0
END
),
0
) AS cache_input_tokens
COALESCE(SUM(${cacheInputSql}), 0) AS cache_input_tokens
FROM model_usage_calls usage
LEFT JOIN tasks ON tasks.id = usage.request_id
GROUP BY is_system`
+28
View File
@@ -125,6 +125,34 @@ describe('token usage aggregation', () => {
})
})
it.each([
{ provider: 'goodbuddy-openai-chat', input: 60, cacheRead: 40, cacheWrite: 0, rate: 0.4 },
{ provider: 'goodbuddy-openai-responses', input: 10, cacheRead: 90, cacheWrite: 0, rate: 0.9 },
{ provider: 'custom', input: 0, cacheRead: 100, cacheWrite: 0, rate: 1 },
{ provider: 'goodbuddy-anthropic', input: 50, cacheRead: 30, cacheWrite: 20, rate: 0.3 },
{ provider: 'openai', input: 100, cacheRead: 0, cacheWrite: 0, rate: 0 }
])('uses complete OpenCode input for $provider ($rate)', ({ provider, input, cacheRead, cacheWrite, rate }) => {
const usage = makeTokenUsage()
usage.records = [{
...usage.records[0]!,
runtime: 'opencode', provider, input, cacheRead, cacheWrite
}]
expect(groupTokenUsage(usage, 'model')[0]).toMatchObject({
cacheInputTokens: 100,
cacheHitRate: rate
})
usage.records[0]!.cacheInput = 100
expect(groupTokenUsage(usage, 'model')[0]?.cacheHitRate).toBe(rate)
// Mixed Runtime groups must sum complete inputs, not average percentages.
usage.records.push({ ...makeTokenUsage().records[1]!, input: 300 })
expect(groupTokenUsage(usage, 'project')[0]).toMatchObject({
cacheInputTokens: 400,
cacheHitRate: cacheRead / 400
})
})
it('uses fallback labels when grouping metadata is unavailable', () => {
const usage = makeTokenUsage()
usage.records = [
+4 -3
View File
@@ -31,7 +31,8 @@ function usesSeparatedAnthropicInput(provider: unknown): boolean {
function usageNumbers(
source: unknown,
provider?: unknown
provider?: unknown,
runtime?: unknown
): TokenUsageTotals {
const values = source as Record<string, unknown>
const read = (preferred: string, legacy: string): number => {
@@ -50,7 +51,7 @@ function usageNumbers(
Number.isFinite(reportedCacheInput)
? reportedCacheInput
: inputTokens +
(usesSeparatedAnthropicInput(provider)
(runtime === 'opencode' || usesSeparatedAnthropicInput(provider)
? cacheReadTokens + cacheWriteTokens
: 0)
@@ -172,7 +173,7 @@ export function groupTokenUsage(
records.push(record)
groupedRecords.set(identity.key, records)
}
const usage = usageNumbers(record, record.provider)
const usage = usageNumbers(record, record.provider, record.runtime)
const existing = rows.get(identity.key)
if (existing) {