diff --git a/src/main/opencode-usage/cache-read-reproduction.test.ts b/src/main/opencode-usage/cache-read-reproduction.test.ts index 015c2922de4..454da7d8199 100644 --- a/src/main/opencode-usage/cache-read-reproduction.test.ts +++ b/src/main/opencode-usage/cache-read-reproduction.test.ts @@ -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, diff --git a/src/main/opencode-usage/cache-write-accounting.test.ts b/src/main/opencode-usage/cache-write-accounting.test.ts new file mode 100644 index 00000000000..883f0cf3f57 --- /dev/null +++ b/src/main/opencode-usage/cache-write-accounting.test.ts @@ -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 }) + } + } +) diff --git a/src/main/opencode-usage/opencode-usage-provider.ts b/src/main/opencode-usage/opencode-usage-provider.ts index 9d91c876028..6e815fb1727 100644 --- a/src/main/opencode-usage/opencode-usage-provider.ts +++ b/src/main/opencode-usage/opencode-usage-provider.ts @@ -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', diff --git a/src/main/opencode-usage/opencode-usage-row-parsing.ts b/src/main/opencode-usage/opencode-usage-row-parsing.ts index 5f6da050fe3..504a15efd63 100644 --- a/src/main/opencode-usage/opencode-usage-row-parsing.ts +++ b/src/main/opencode-usage/opencode-usage-row-parsing.ts @@ -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 diff --git a/src/main/opencode-usage/opencode2-session-usage.test.ts b/src/main/opencode-usage/opencode2-session-usage.test.ts index 46e6909274a..12135c9af31 100644 --- a/src/main/opencode-usage/opencode2-session-usage.test.ts +++ b/src/main/opencode-usage/opencode2-session-usage.test.ts @@ -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 }) ]) diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index 0e5c39eb69a..9ca0d8e73eb 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -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, diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 06fd19a6a5e..5cb9eaab883 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -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) diff --git a/src/main/opencode-usage/types.ts b/src/main/opencode-usage/types.ts index cf8d2c6a8d0..53a4ea673d5 100644 --- a/src/main/opencode-usage/types.ts +++ b/src/main/opencode-usage/types.ts @@ -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 diff --git a/src/main/usage/agent-token-usage.ts b/src/main/usage/agent-token-usage.ts index 36cada6cf01..bc6868d10fc 100644 --- a/src/main/usage/agent-token-usage.ts +++ b/src/main/usage/agent-token-usage.ts @@ -40,8 +40,18 @@ export function codexOpenCodeTokenSessions( 0 ), output_tokens: locations.reduce((sum, entry) => sum + entry.outputTokens, 0), - cached_input_tokens: locations.reduce((sum, entry) => sum + entry.cachedInputTokens, 0), - cache_write_input_tokens: 0 + cached_input_tokens: locations.reduce( + (sum, entry) => + sum + + entry.cachedInputTokens - + ('cacheWriteInputTokens' in entry ? (entry.cacheWriteInputTokens ?? 0) : 0), + 0 + ), + cache_write_input_tokens: locations.reduce( + (sum, entry) => + sum + ('cacheWriteInputTokens' in entry ? (entry.cacheWriteInputTokens ?? 0) : 0), + 0 + ) } ] }) diff --git a/src/renderer/src/components/stats/CodexUsageDailyChart.tsx b/src/renderer/src/components/stats/CodexUsageDailyChart.tsx index 3ac8b94dadb..6c8be76711d 100644 --- a/src/renderer/src/components/stats/CodexUsageDailyChart.tsx +++ b/src/renderer/src/components/stats/CodexUsageDailyChart.tsx @@ -48,7 +48,7 @@ export function CodexUsageDailyChart({ daily }: CodexUsageDailyChartProps): Reac { key: 'input', label: translate('auto.components.stats.CodexUsageDailyChart.99a91d3143', 'Input'), - value: entry.inputTokens, + value: Math.max(0, entry.inputTokens - entry.cachedInputTokens), className: 'bg-sky-500/80' }, {