mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
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:
@@ -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
|
||||
})
|
||||
])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user