codeburn/tests/hermes-session-ledger.test.ts
Ulises 5fe803440a
feat(cli): filter usage by billing route and mode (#1486)
* feat(cli): filter usage by billing route and mode

Carry provider-observed billing provenance through Hermes and the session cache, register the real OpenRouter route, and add call-level route/billing filters to models, sessions, export, and audit.

Keep unknown billing unclassified, preserve existing pricing, and repair legacy included Hermes ledger baselines without rewriting genuine multi-observation history.

* feat(opencode): preserve OpenRouter route provenance

Carry OpenCode providerID through legacy SQLite, v2, file, and session-rollup paths so OpenRouter calls remain distinct from direct traffic. Invalidate OpenCode and KiloCode caches and cover strict route matching.

* feat(opencode): classify Amazon Bedrock route

Map OpenCode's exact amazon-bedrock providerID to the existing metered Bedrock route, including model families that cannot rely on Anthropic/OpenAI ID shapes. Invalidate OpenCode and KiloCode caches across the route expansion.

* docs(opencode): document billing route provenance

---------

Co-authored-by: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com>
2026-09-19 11:32:06 -07:00

338 lines
12 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
applyHermesSnapshot,
HERMES_SESSION_LEDGER_VERSION,
hermesBaselineKey,
hermesObservationKey,
hermesSessionLedgerPath,
isHermesLedgerPublicationError,
isHermesObservationKey,
loadHermesSessionLedger,
parseHermesDedupKey,
persistHermesSessionLedger,
recordHermesSnapshot,
resetHermesSessionLedgerForTests,
seedHermesCursorsFromProviderSection,
setHermesLedgerNow,
type HermesSnapshot,
} from '../src/hermes-session-ledger.js'
import type { CachedCall, ProviderSection } from '../src/session-cache.js'
let cacheDir: string
let originalCacheDir: string | undefined
beforeEach(async () => {
cacheDir = await mkdtemp(join(tmpdir(), 'hermes-ledger-unit-'))
originalCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = cacheDir
resetHermesSessionLedgerForTests()
})
afterEach(async () => {
resetHermesSessionLedgerForTests()
if (originalCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = originalCacheDir
await rm(cacheDir, { recursive: true, force: true })
})
function snap(overrides: Partial<HermesSnapshot> = {}): HermesSnapshot {
const { tokens: tokenOverrides, ...rest } = overrides
return {
profile: 'default',
sessionId: 's1',
startedAt: '2026-08-21T10:00:00.000Z',
observedAt: '2026-08-22T10:00:00.000Z',
costUSD: 0.10,
costBasis: 'actual',
...rest,
tokens: {
inputTokens: 100,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
...tokenOverrides,
},
}
}
describe('hermes session ledger unit', () => {
it('parses baseline and observation keys', () => {
expect(parseHermesDedupKey('hermes:default:abc')).toEqual({ profile: 'default', sessionId: 'abc' })
expect(parseHermesDedupKey('hermes:coder:abc:obs:1')).toEqual({
profile: 'coder',
sessionId: 'abc',
observationIndex: 1,
})
expect(isHermesObservationKey('hermes:default:abc')).toBe(false)
expect(isHermesObservationKey('hermes:default:abc:obs:2')).toBe(true)
expect(hermesBaselineKey('default', 'abc')).toBe('hermes:default:abc')
expect(hermesObservationKey('default', 'abc', 1)).toBe('hermes:default:abc:obs:1')
})
it('first observation is behavioral lifetime at started_at', () => {
const { cursor, dirty } = applyHermesSnapshot(undefined, snap())
expect(dirty).toBe(true)
expect(cursor.observations).toHaveLength(1)
expect(cursor.observations[0]).toMatchObject({
index: 0,
timestamp: '2026-08-21T10:00:00.000Z',
inputTokens: 100,
costUSD: 0.10,
supplementaryAccounting: false,
})
expect(cursor.lastSeen.inputTokens).toBe(100)
})
it('growth emits a positive delta only', () => {
const first = applyHermesSnapshot(undefined, snap()).cursor
const { cursor, dirty } = applyHermesSnapshot(first, snap({
tokens: { inputTokens: 150, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0.15,
}))
expect(dirty).toBe(true)
expect(cursor.observations).toHaveLength(2)
expect(cursor.observations[1]!.index).toBe(1)
expect(cursor.observations[1]!.timestamp).toBe('2026-08-22T10:00:00.000Z')
expect(cursor.observations[1]!.inputTokens).toBe(50)
expect(cursor.observations[1]!.costUSD).toBeCloseTo(0.05)
expect(cursor.observations[1]!.supplementaryAccounting).toBe(true)
expect(cursor.lastSeen).toMatchObject({ inputTokens: 150, costUSD: 0.15 })
})
it('equal totals re-emit stored observations with no new row', () => {
const first = applyHermesSnapshot(undefined, snap()).cursor
const { cursor, dirty } = applyHermesSnapshot(first, snap())
expect(dirty).toBe(false)
expect(cursor.observations).toHaveLength(1)
expect(cursor).toBe(first)
})
it('150 → 0 advances last-seen to zero and emits no observation', () => {
const first = applyHermesSnapshot(undefined, snap({
tokens: { inputTokens: 150, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0.15,
})).cursor
const { cursor, dirty } = applyHermesSnapshot(first, snap({
tokens: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0,
}))
expect(dirty).toBe(true)
expect(cursor.observations).toHaveLength(1)
expect(cursor.lastSeen.inputTokens).toBe(0)
expect(cursor.lastSeen.costUSD).toBe(0)
})
it('0 → 40 after a zero reset emits one +40 weight-0 observation', () => {
const first = applyHermesSnapshot(undefined, snap({
tokens: { inputTokens: 150, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
})).cursor
const zeroed = applyHermesSnapshot(first, snap({
tokens: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0,
})).cursor
const { cursor } = applyHermesSnapshot(zeroed, snap({
tokens: { inputTokens: 40, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0.04,
}))
expect(cursor.observations).toHaveLength(2)
expect(cursor.observations[1]).toMatchObject({
index: 1,
inputTokens: 40,
costUSD: 0.04,
supplementaryAccounting: true,
})
expect(cursor.observations.some(o => o.inputTokens < 0 || o.costUSD < 0)).toBe(false)
})
it('a cost-basis shrink is a cost reset without a negative', () => {
const first = applyHermesSnapshot(undefined, snap()).cursor
const { cursor } = applyHermesSnapshot(first, snap({
tokens: { inputTokens: 150, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0.05,
costBasis: 'estimated',
}))
expect(cursor.observations).toHaveLength(2)
expect(cursor.observations[1]).toMatchObject({
inputTokens: 50,
costUSD: 0,
supplementaryAccounting: true,
})
expect(cursor.lastSeen.costUSD).toBe(0.05)
expect(cursor.lastSeen.costBasis).toBe('estimated')
})
it('does not relabel a genuine multi-observation history on a basis-only change', () => {
const first = applyHermesSnapshot(undefined, snap({ costUSD: 0 })).cursor
const grown = applyHermesSnapshot(first, snap({
tokens: { inputTokens: 150, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0,
costBasis: 'actual',
})).cursor
const corrected = applyHermesSnapshot(grown, snap({
tokens: { inputTokens: 150, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0,
costBasis: 'included',
})).cursor
expect(corrected.observations.map(observation => observation.costBasis)).toEqual(['actual', 'actual'])
expect(corrected.lastSeen.costBasis).toBe('included')
})
it('clock is injectable for observation timestamps', async () => {
setHermesLedgerNow(() => new Date('2026-08-22T15:30:00.000Z'))
const cursor = await recordHermesSnapshot(snap({
observedAt: new Date('2026-08-22T15:30:00.000Z').toISOString(),
}))
const grown = await recordHermesSnapshot(snap({
tokens: { inputTokens: 120, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
costUSD: 0.12,
observedAt: new Date('2026-08-22T15:30:00.000Z').toISOString(),
}))
expect(grown.observations[1]!.timestamp).toBe('2026-08-22T15:30:00.000Z')
expect(cursor.observations[0]!.timestamp).toBe('2026-08-21T10:00:00.000Z')
})
it('seed writes one behavioral observation and does not overwrite existing keys', async () => {
const call: CachedCall = {
provider: 'hermes',
model: 'gpt-5.5',
usage: {
inputTokens: 100,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
cacheCreationOneHourTokens: 0,
},
costUSD: 0.10,
speed: 'standard',
timestamp: '2026-08-21T10:00:00.000Z',
tools: ['Read'],
bashCommands: [],
skills: [],
subagentTypes: [],
deduplicationKey: 'hermes:default:seeded',
}
const section: ProviderSection = {
envFingerprint: 'test',
files: {
'/tmp/state.db#hermes-session=seeded': {
fingerprint: { dev: 1, ino: 1, mtimeMs: 1, sizeBytes: 1 },
mcpInventory: [],
turns: [{ timestamp: call.timestamp, sessionId: 'seeded', userMessage: 'hi', calls: [call] }],
},
},
}
await seedHermesCursorsFromProviderSection(section)
const first = loadHermesSessionLedger()
expect(first.cursors['default']!['seeded']!.observations).toHaveLength(1)
expect(first.cursors['default']!['seeded']!.observations[0]).toMatchObject({
index: 0,
timestamp: '2026-08-21T10:00:00.000Z',
inputTokens: 100,
supplementaryAccounting: false,
})
section.files['/tmp/state.db#hermes-session=seeded']!.turns[0]!.calls[0] = {
...call,
usage: { ...call.usage, inputTokens: 999 },
timestamp: '2026-08-22T10:00:00.000Z',
}
await seedHermesCursorsFromProviderSection(section)
const second = loadHermesSessionLedger()
expect(second.cursors['default']!['seeded']!.observations).toHaveLength(1)
expect(second.cursors['default']!['seeded']!.lastSeen.inputTokens).toBe(100)
})
it('seeds cached subscription billing as included, not actual', async () => {
const call: CachedCall = {
provider: 'hermes',
model: 'gpt-5.6-sol',
usage: {
inputTokens: 100,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
cacheCreationOneHourTokens: 0,
},
costUSD: 0,
isEstimated: false,
billing: 'subscription',
speed: 'standard',
timestamp: '2026-08-21T10:00:00.000Z',
tools: [],
bashCommands: [],
skills: [],
subagentTypes: [],
deduplicationKey: 'hermes:default:subscription-seed',
}
const section: ProviderSection = {
envFingerprint: 'test',
files: {
'/tmp/state.db#hermes-session=subscription-seed': {
fingerprint: { dev: 1, ino: 1, mtimeMs: 1, sizeBytes: 1 },
mcpInventory: [],
turns: [{ timestamp: call.timestamp, sessionId: 'subscription-seed', userMessage: 'hi', calls: [call] }],
},
},
}
await seedHermesCursorsFromProviderSection(section)
const observation = loadHermesSessionLedger().cursors['default']!['subscription-seed']!.observations[0]!
expect(observation.costBasis).toBe('included')
})
it('publication failure is a typed retryable error', async () => {
await mkdir(hermesSessionLedgerPath())
let caught: unknown
try {
await persistHermesSessionLedger({ version: HERMES_SESSION_LEDGER_VERSION, cursors: {} })
} catch (err) {
caught = err
}
expect(isHermesLedgerPublicationError(caught)).toBe(true)
})
it('ignores a superseded v1 ledger and removes it from the cache dir', async () => {
const v1Path = join(cacheDir, 'hermes-session-ledger.v1.json')
await writeFile(v1Path, JSON.stringify({
version: 1,
cursors: {
default: {
historical: {
profile: 'default',
sessionId: 'historical',
lastSeen: {
inputTokens: 1,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
costUSD: 4603,
costBasis: 'calculated',
},
observations: [],
},
},
},
}))
expect(existsSync(v1Path)).toBe(true)
// No v3 file: the loader starts empty, and the v1 file it can never read
// again must not survive the load.
expect(loadHermesSessionLedger().cursors).toEqual({})
expect(existsSync(v1Path)).toBe(false)
})
})