Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/analyze/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `planUsageFromArchive(plan, { pricing, db, now })` ([#91](https://github.com/AgentWorkforce/burn/issues/91)) — computes `PlanUsage` for a plan via one `SUM(...) GROUP BY (source, model)` query against the archive's `turns` table instead of a full ledger scan. Returns the same shape as `computePlanUsage` so callers can swap paths cleanly. Reuses `costForTurn`'s source-aware reasoning override, so Codex `output_tokens` is not double-billed against `usage.reasoning`.

## [0.31.0] - 2026-04-27

### Added
Expand Down
8 changes: 6 additions & 2 deletions packages/analyze/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,12 @@ export {
findContextFiles,
loadContextFile,
} from './context-md.js';
export { computePlanUsage, cycleBounds } from './plan-usage.js';
export type { ComputePlanUsageOptions, PlanUsage } from './plan-usage.js';
export { computePlanUsage, cycleBounds, planUsageFromArchive } from './plan-usage.js';
export type {
ComputePlanUsageFromArchiveOptions,
ComputePlanUsageOptions,
PlanUsage,
} from './plan-usage.js';
export {
emptyFidelitySummary,
hasMinimumFidelity,
Expand Down
322 changes: 320 additions & 2 deletions packages/analyze/src/plan-usage.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { strict as assert } from 'node:assert';
import { DatabaseSync } from 'node:sqlite';
import { describe, it } from 'node:test';

import type { Plan } from '@relayburn/ledger';
import type { TurnRecord } from '@relayburn/reader';
import type { SourceKind, TurnRecord } from '@relayburn/reader';

import { computePlanUsage, cycleBounds } from './plan-usage.js';
import { computePlanUsage, cycleBounds, planUsageFromArchive } from './plan-usage.js';
import type { PricingTable } from './pricing.js';

const PRICING: PricingTable = {
Expand Down Expand Up @@ -205,3 +206,320 @@ describe('computePlanUsage', () => {
assert.equal(u.spentUsd, 3);
});
});

// Minimal subset of the real `archive.sqlite` `turns` schema — just the
// columns `planUsageFromArchive` reads. Built per-test in :memory: so we
// don't need a real archive build / RELAYBURN_HOME shuffle.
const ARCHIVE_TURNS_DDL = `
CREATE TABLE turns (
source TEXT NOT NULL,
session_id TEXT NOT NULL,
message_id TEXT NOT NULL,
ts TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_create_5m_tokens INTEGER NOT NULL DEFAULT 0,
cache_create_1h_tokens INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source, session_id, message_id)
);
CREATE INDEX idx_turns_ts ON turns(ts);
`;

interface ArchiveTurnRow {
source: SourceKind;
ts: string;
model: string;
inputTokens?: number;
outputTokens?: number;
reasoningTokens?: number;
cacheReadTokens?: number;
cacheCreate5mTokens?: number;
cacheCreate1hTokens?: number;
}

function makeArchive(rows: ArchiveTurnRow[]): DatabaseSync {
const db = new DatabaseSync(':memory:');
db.exec(ARCHIVE_TURNS_DDL);
const insert = db.prepare(`
INSERT INTO turns (
source, session_id, message_id, ts, model,
input_tokens, output_tokens, reasoning_tokens,
cache_read_tokens, cache_create_5m_tokens, cache_create_1h_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (let i = 0; i < rows.length; i++) {
const r = rows[i]!;
insert.run(
r.source,
`s-${i}`,
`m-${i}`,
r.ts,
r.model,
r.inputTokens ?? 0,
r.outputTokens ?? 0,
r.reasoningTokens ?? 0,
r.cacheReadTokens ?? 0,
r.cacheCreate5mTokens ?? 0,
r.cacheCreate1hTokens ?? 0,
);
}
return db;
}

describe('planUsageFromArchive', () => {
const now = new Date('2026-04-15T00:00:00.000Z'); // 14 days into a calendar cycle

it('parity with computePlanUsage on the same fixture', () => {
const fixtureTurns: TurnRecord[] = [
turn({ ts: '2026-04-05T00:00:00.000Z', inputTokens: 1_000_000 }),
turn({ ts: '2026-04-10T00:00:00.000Z', inputTokens: 1_000_000 }),
// Outside cycle: previous month
turn({ ts: '2026-03-25T00:00:00.000Z', inputTokens: 5_000_000 }),
];
const memUsage = computePlanUsage(plan, fixtureTurns, { pricing: PRICING, now });

const db = makeArchive(
fixtureTurns.map((t) => ({
source: t.source,
ts: t.ts,
model: t.model,
inputTokens: t.usage.input,
outputTokens: t.usage.output,
})),
);
try {
const archiveUsage = planUsageFromArchive(plan, { pricing: PRICING, db, now });
// Byte-identical PlanUsage shape on the parity fixture.
assert.deepEqual(archiveUsage, memUsage);
} finally {
db.close();
}
});

it('reset-day boundary: turn at cycleEnd lands in the next cycle', () => {
// resetDay=1 cycle for `now=2026-04-15` is [2026-04-01, 2026-05-01).
// A turn at exactly 2026-05-01T00:00:00.000Z must NOT count toward this
// cycle (matches the `< cycleEndMs` half-open in `computePlanUsage`).
const db = makeArchive([
// boundary-low: first instant of the cycle → counted
{
source: 'claude-code',
ts: '2026-04-01T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
// strictly inside
{
source: 'claude-code',
ts: '2026-04-14T23:59:59.999Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
// boundary-high: cycle end → next cycle, must be excluded
{
source: 'claude-code',
ts: '2026-05-01T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
// far past
{
source: 'claude-code',
ts: '2026-03-31T23:59:59.999Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
]);
try {
const u = planUsageFromArchive(plan, { pricing: PRICING, db, now });
// 2 in-window × $3 = $6
assert.equal(u.spentUsd, 6);
} finally {
db.close();
}
});

it('claude provider only counts claude-code/anthropic-api turns', () => {
const db = makeArchive([
{
source: 'claude-code',
ts: '2026-04-05T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
{
source: 'anthropic-api',
ts: '2026-04-06T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
{
source: 'codex',
ts: '2026-04-07T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
]);
try {
const u = planUsageFromArchive(plan, { pricing: PRICING, db, now });
// 2 claude turns × $3 = $6, codex excluded
assert.equal(u.spentUsd, 6);
} finally {
db.close();
}
});

it('cursor provider returns $0 without issuing a query against unknown sources', () => {
const cursorPlan: Plan = { ...plan, provider: 'cursor', id: 'cursor-pro' };
const db = makeArchive([
// Even if some hypothetical source called 'cursor' lived in the table,
// the helper short-circuits on the empty source list — see
// `providerSources('cursor')`.
{
source: 'claude-code',
ts: '2026-04-05T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 5_000_000,
},
]);
try {
const u = planUsageFromArchive(cursorPlan, { pricing: PRICING, db, now });
assert.equal(u.spentUsd, 0);
assert.equal(u.projectedEndOfCycleUsd, 0);
} finally {
db.close();
}
});

it('custom provider counts every source', () => {
const customPlan: Plan = { ...plan, provider: 'custom' };
const db = makeArchive([
{
source: 'claude-code',
ts: '2026-04-05T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
{
source: 'codex',
ts: '2026-04-06T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
{
source: 'opencode',
ts: '2026-04-07T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
]);
try {
const u = planUsageFromArchive(customPlan, { pricing: PRICING, db, now });
assert.equal(u.spentUsd, 9);
} finally {
db.close();
}
});

it('flags limitedData when fewer than 7 days have elapsed', () => {
const earlyNow = new Date('2026-04-04T00:00:00.000Z');
const db = makeArchive([]);
try {
const u = planUsageFromArchive(plan, { pricing: PRICING, db, now: earlyNow });
assert.equal(u.daysElapsed, 3);
assert.equal(u.limitedData, true);
} finally {
db.close();
}
});

it('does not double-bill Codex reasoning tokens (uses same source override as costForTurn)', () => {
const customPlan: Plan = { ...plan, provider: 'custom' };
// 1M output × $15 = $15. With reasoning double-billed at the output rate
// we'd see $15 + $15 = $30; the source-aware override keeps it at $15.
const db = makeArchive([
{
source: 'codex',
ts: '2026-04-05T00:00:00.000Z',
model: 'claude-sonnet-4-6',
outputTokens: 1_000_000,
reasoningTokens: 1_000_000,
},
]);
try {
const u = planUsageFromArchive(customPlan, { pricing: PRICING, db, now });
assert.equal(u.spentUsd, 15);
} finally {
db.close();
}
});

it('honors a custom resetDay (anniversary cycle)', () => {
const anniversaryPlan: Plan = { ...plan, resetDay: 15 };
// now = April 20, cycle started April 15, ends May 15
const db = makeArchive([
// inside
{
source: 'claude-code',
ts: '2026-04-16T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 1_000_000,
},
// before cycle start — excluded
{
source: 'claude-code',
ts: '2026-04-10T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 5_000_000,
},
]);
try {
const u = planUsageFromArchive(anniversaryPlan, {
pricing: PRICING,
db,
now: new Date('2026-04-20T00:00:00.000Z'),
});
assert.equal(u.spentUsd, 3);
} finally {
db.close();
}
});

it('groups by (source, model) so multi-model spend aggregates correctly', () => {
const customPlan: Plan = { ...plan, provider: 'custom' };
const pricing: PricingTable = {
...PRICING,
'gpt-5-mini': {
input: 1,
output: 5,
cacheRead: 0.1,
cacheWrite: 1.25,
reasoningMode: 'same_as_output',
},
};
const db = makeArchive([
{
source: 'claude-code',
ts: '2026-04-05T00:00:00.000Z',
model: 'claude-sonnet-4-6',
inputTokens: 2_000_000,
},
{
source: 'codex',
ts: '2026-04-06T00:00:00.000Z',
model: 'gpt-5-mini',
outputTokens: 1_000_000,
},
]);
try {
const u = planUsageFromArchive(customPlan, { pricing, db, now });
// 2M input × $3 = $6 (claude) + 1M output × $5 = $5 (gpt-5-mini) = $11
assert.equal(u.spentUsd, 11);
} finally {
db.close();
}
});
});
Loading