fix(usage): include OpenCode cached input in totals

Normalize OpenCode cache reads and writes consistently across usage views, persistence and telemetry.

Continues #20187.

Co-authored-by: griffinmartin <griffinmartin@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-02 20:41:18 -07:00
committed by GitHub
co-authored by griffinmartin
parent 6e6f651380
commit f1463683ee
10 changed files with 162 additions and 31 deletions
@@ -41,7 +41,7 @@ it('preserves a local cache-heavy session through every usage projection', async
}
const { sessions, dailyAggregates } = await parseOpenCodeUsageDatabase(path, () => null)
const counters = {
inputTokens: 13_634_611,
inputTokens: 278_041_631,
outputTokens: 207_892,
reasoningOutputTokens: 97_467,
cachedInputTokens: 264_407_020,
@@ -0,0 +1,101 @@
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { codexOpenCodeTokenSessions } from '../usage/agent-token-usage'
import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import { getDefaultState, normalizePersistedState } from './persisted-state-normalization'
import { writeOpenCodeUsageDatabase } from './opencode-usage-sqlite-fixture'
import { parseOpenCodeUsageDatabase } from './scanner'
import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing'
it.each([
{ input: 638, read: 642944, write: 1024, total: 644706 },
{ input: 0, read: 0, write: 1024, total: 1124 }
])(
'preserves cache writes and uncached input in $total tokens',
({ input, read, write, total }) => {
const parsed = parseOpenCodeUsageRow({
id: 'cache-write-message',
session_id: 'cache-write-session',
time_created: 1_777_777_700_000,
time_updated: null,
directory: null,
title: null,
worktree: null,
session_model: null,
data: JSON.stringify({
tokens: { input, output: 100, reasoning: 0, total, cache: { read, write } }
})
})
expect(parsed).toMatchObject({
inputTokens: input + read + write,
cachedInputTokens: read + write,
cacheWriteInputTokens: write,
outputTokens: 100,
totalTokens: total
})
expect((parsed?.inputTokens ?? 0) - (parsed?.cachedInputTokens ?? 0)).toBe(input)
}
)
it.each(['v1', 'v2-only'] as const)(
'preserves separate cache read and write telemetry after %s aggregation and reload',
async (generation) => {
const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-cache-buckets-')))
try {
const path = join(directory, 'opencode.db')
const sessions = [
{
id: 'cache-session',
directory,
tokensInput: 638,
tokensOutput: 100,
tokensCacheRead: 642944,
tokensCacheWrite: 1024
}
]
writeOpenCodeUsageDatabase(path, {
generation,
legacySessions: sessions,
v2Sessions: sessions,
worktree: directory
})
const resolver = await createUsageWorktreeResolver([
{
repoId: 'repo',
worktreeId: 'folder',
path: directory,
displayName: 'Repo'
}
])
const database = await parseOpenCodeUsageDatabase(path, resolver)
const state = normalizePersistedState({
...getDefaultState(),
processedDatabases: [structuredClone(database)],
sessions: structuredClone(database.sessions),
dailyAggregates: structuredClone(database.dailyAggregates)
})
expect(state.dailyAggregates[0]).toMatchObject({
inputTokens: 644606,
cachedInputTokens: 643968,
cacheWriteInputTokens: 1024
})
expect(state.sessions[0]?.modelBreakdown[0]?.cacheWriteInputTokens).toBe(1024)
expect(state.sessions[0]?.locationModelBreakdown[0]?.cacheWriteInputTokens).toBe(1024)
for (const sessions of [state.sessions, state.processedDatabases[0]?.sessions ?? []]) {
expect(codexOpenCodeTokenSessions(sessions)).toEqual([
{
providerSessionId: 'cache-session',
input_tokens: 638,
output_tokens: 100,
cached_input_tokens: 642944,
cache_write_input_tokens: 1024
}
])
}
} finally {
rmSync(directory, { recursive: true, force: true })
}
}
)
@@ -9,7 +9,8 @@ import type {
// Why: v4 reads OpenCode 2's `session_v2` table; v3 caches miss every v2 session.
// v5 merges a migrated session's two rows per column instead of picking one, so
// v4 caches hold zeroed costs and pre-migration metadata.
export const OPENCODE_USAGE_SCHEMA_VERSION = 5
// v6 normalizes input; v7 preserves writes separately for telemetry.
export const OPENCODE_USAGE_SCHEMA_VERSION = 7
export const openCodeUsageProvider = {
id: 'opencode',
@@ -77,14 +77,16 @@ export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParse
return null
}
const cache = parseJsonObject(tokens.cache)
const inputTokens = ensureNumber(tokens.input)
const uncachedInputTokens = ensureNumber(tokens.input)
const outputTokens = ensureNumber(tokens.output)
const reasoningOutputTokens = ensureNumber(tokens.reasoning)
// Cache reads can exceed input; max avoids double-counting when total already includes them.
const cachedInputTokens = ensureNumber(cache?.read)
// OpenCode reports uncached input separately; Orca includes both cache buckets in input.
const cacheWriteInputTokens = ensureNumber(cache?.write)
const cachedInputTokens = ensureNumber(cache?.read) + cacheWriteInputTokens
const inputTokens = uncachedInputTokens + cachedInputTokens
const totalTokens = Math.max(
ensureNumber(tokens.total),
inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens
inputTokens + outputTokens + reasoningOutputTokens
)
if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) {
@@ -104,6 +106,7 @@ export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParse
estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null,
inputTokens,
cachedInputTokens,
cacheWriteInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens
@@ -81,10 +81,10 @@ describe('OpenCode 2 session_v2 usage', () => {
cwd: WORKTREE,
model: 'anthropic/claude-sonnet-4-5',
estimatedCostUsd: 0.5,
inputTokens: 100,
inputTokens: 1300,
outputTokens: 20,
reasoningOutputTokens: 5,
cachedInputTokens: 900,
cachedInputTokens: 1200,
totalTokens: 1325
})
])
@@ -106,8 +106,9 @@ describe('OpenCode 2 session_v2 usage', () => {
})
const [event] = readEvents(path)
expect(event?.cachedInputTokens).toBe(5_000)
// Cache writes are not billed as input, so they only reach the total.
expect(event?.cachedInputTokens).toBe(7_000)
expect(event?.inputTokens).toBe(7_010)
// Both cache buckets contribute to input; total counts them once.
expect(event?.totalTokens).toBe(7_011)
})
@@ -130,7 +131,7 @@ describe('OpenCode 2 session_v2 usage', () => {
const events = readEvents(path)
expect(events.map((event) => event.sessionId)).toEqual(['ses_shared', 'ses_v2_only'])
expect(events[0]?.inputTokens).toBe(180)
expect(events[0]?.inputTokens).toBe(240)
expect(events[0]?.cachedInputTokens).toBe(60)
})
@@ -211,9 +212,9 @@ describe('OpenCode 2 session_v2 usage', () => {
expect.objectContaining({
sessionId: 'ses_v1',
estimatedCostUsd: 1.25,
inputTokens: 11,
inputTokens: 20,
outputTokens: 3,
cachedInputTokens: 7,
cachedInputTokens: 9,
totalTokens: 23
})
])
@@ -240,7 +241,7 @@ describe('OpenCode 2 session_v2 usage', () => {
db.close()
expect(readEvents(path)).toEqual([
expect.objectContaining({ sessionId: 'ses_msg', inputTokens: 60, cachedInputTokens: 12 })
expect.objectContaining({ sessionId: 'ses_msg', inputTokens: 75, cachedInputTokens: 15 })
])
})
@@ -373,7 +374,7 @@ describe('OpenCode 2 migrated session column merge', () => {
const events = readEvents(path)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
inputTokens: 1000,
inputTokens: 2200,
cachedInputTokens: 1200,
estimatedCostUsd: 2,
totalTokens: 2200
@@ -426,9 +427,9 @@ describe('OpenCode 2 migrated session column merge', () => {
cwd: WORKTREE,
model: 'anthropic/claude-sonnet-4-5',
estimatedCostUsd: 1.25,
inputTokens: 11,
inputTokens: 20,
outputTokens: 3,
cachedInputTokens: 7,
cachedInputTokens: 9,
totalTokens: 23
})
])
+9 -8
View File
@@ -127,11 +127,12 @@ describe('parseOpenCodeUsageRow', () => {
cwd: `${WORKTREE}/packages/app`,
model: 'anthropic/claude-sonnet-4-5',
estimatedCostUsd: 0.0123,
inputTokens: 1000,
cachedInputTokens: 400,
inputTokens: 1425,
cachedInputTokens: 425,
cacheWriteInputTokens: 25,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1750
totalTokens: 1775
})
})
@@ -165,7 +166,7 @@ describe('parseOpenCodeUsageRow', () => {
})
expect(parsed).toMatchObject({
inputTokens: 100,
inputTokens: 10_100,
cachedInputTokens: 10_000,
outputTokens: 20,
reasoningOutputTokens: 5,
@@ -246,8 +247,8 @@ describe('parseOpenCodeUsageDatabase', () => {
primaryModel: 'anthropic/claude-sonnet-4-5',
primaryProjectLabel: 'Repo',
eventCount: 1,
totalInputTokens: 1000,
totalCachedInputTokens: 250,
totalInputTokens: 1325,
totalCachedInputTokens: 325,
totalOutputTokens: 500,
totalReasoningOutputTokens: 100,
totalTokens: 1925,
@@ -256,8 +257,8 @@ describe('parseOpenCodeUsageDatabase', () => {
expect(parsed.dailyAggregates).toEqual([
expect.objectContaining({
projectLabel: 'Repo',
inputTokens: 1000,
cachedInputTokens: 250,
inputTokens: 1325,
cachedInputTokens: 325,
outputTokens: 500,
reasoningOutputTokens: 100,
totalTokens: 1925,
+11 -3
View File
@@ -32,17 +32,25 @@ function addCost(left: number | null, right: number | null): number | null {
return (left ?? 0) + (right ?? 0)
}
type OpenCodeUsageMetric = { estimatedCostUsd: number | null }
type OpenCodeUsageMetric = {
estimatedCostUsd: number | null
cacheWriteInputTokens?: number
}
const openCodeUsageAggregation = createUsageEventAggregation<
OpenCodeUsageAttributedEvent,
OpenCodeUsageMetric
>({
metric: {
empty: () => ({ estimatedCostUsd: null }),
fromEvent: (event) => ({ estimatedCostUsd: event.estimatedCostUsd }),
empty: () => ({ estimatedCostUsd: null, cacheWriteInputTokens: 0 }),
fromEvent: (event) => ({
estimatedCostUsd: event.estimatedCostUsd,
cacheWriteInputTokens: event.cacheWriteInputTokens ?? 0
}),
fold: (target, source) => {
target.estimatedCostUsd = addCost(target.estimatedCostUsd, source.estimatedCostUsd)
target.cacheWriteInputTokens =
(target.cacheWriteInputTokens ?? 0) + (source.cacheWriteInputTokens ?? 0)
}
},
cloneSessionForMerge: (session) => structuredClone(session)
+6
View File
@@ -12,6 +12,7 @@ export type OpenCodeUsageLocationBreakdown = {
eventCount: number
inputTokens: number
cachedInputTokens: number
cacheWriteInputTokens?: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
@@ -25,6 +26,7 @@ export type OpenCodeUsageModelBreakdown = {
eventCount: number
inputTokens: number
cachedInputTokens: number
cacheWriteInputTokens?: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
@@ -39,6 +41,7 @@ export type OpenCodeUsageLocationModelBreakdown = {
eventCount: number
inputTokens: number
cachedInputTokens: number
cacheWriteInputTokens?: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
@@ -58,6 +61,7 @@ export type OpenCodeUsageSession = {
eventCount: number
totalInputTokens: number
totalCachedInputTokens: number
cacheWriteInputTokens?: number
totalOutputTokens: number
totalReasoningOutputTokens: number
totalTokens: number
@@ -77,6 +81,7 @@ export type OpenCodeUsageDailyAggregate = {
eventCount: number
inputTokens: number
cachedInputTokens: number
cacheWriteInputTokens?: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
@@ -117,6 +122,7 @@ export type OpenCodeUsageParsedEvent = {
estimatedCostUsd: number | null
inputTokens: number
cachedInputTokens: number
cacheWriteInputTokens?: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number