Skip to content

Commit 67c72ef

Browse files
committed
fix(cli): recover Amp usage from tokens.total and ledger-less threads
Two dropped-usage gaps vs ccusage: - Ledger events reading only tokens.input/output ignored tokens.total, so a total-only event ({tokens:{total:N}}) computed to zero and was skipped. Fold an explicit total that exceeds the itemized parts into billable output. - A thread with no usageLedger returned []; the current Amp schema stores usage on each assistant message. Fall back to per-message usage (ccusage parse_message_usage).
1 parent e2ddc15 commit 67c72ef

2 files changed

Lines changed: 232 additions & 6 deletions

File tree

packages/cli/src/adapters/amp.ts

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,15 @@ async function parseAmpSessionFile(
5050
const rawEvents = Array.isArray(ledger.events)
5151
? ledger.events.filter(isPlainObject) as Record<string, unknown>[]
5252
: []
53+
const state = new SessionParserState(filePath, options, event => baseAmpEvent(event))
54+
state.sessionId = sessionId
55+
56+
// Prefer the usage ledger. A thread with no ledger (older / other Amp schema)
57+
// still carries usage on each assistant message, so fall back to that instead of
58+
// dropping the whole thread — mirrors ccusage read_thread_file -> parse_message_usage.
5359
if (rawEvents.length === 0) {
54-
return []
60+
collectAmpMessageUsage(state, messages, sessionId, threadId, options)
61+
return state.events.filter(event => matchesBackfillFilters(event, options))
5562
}
5663

5764
// Amp ledger entries can be written out of order; sort by timestamp so the
@@ -62,9 +69,6 @@ async function parseAmpSessionFile(
6269
return ta.localeCompare(tb)
6370
})
6471

65-
const state = new SessionParserState(filePath, options, event => baseAmpEvent(event))
66-
state.sessionId = sessionId
67-
6872
const firstTs = timestampFrom(stringField(events[0], 'timestamp')) || new Date().toISOString()
6973
state.ensureSessionStarted(firstTs, 0)
7074

@@ -80,7 +84,10 @@ async function parseAmpSessionFile(
8084
const cache = cacheTokensFor(messages, toMessageId)
8185
const cacheRead = cache.cacheReadInputTokens
8286
const cacheWrite = cache.cacheCreationInputTokens
83-
const totalTokens = input + output + cacheRead + cacheWrite
87+
// Fold an explicit tokens.total that exceeds the itemized parts into billable
88+
// output (ccusage apply_total_token_fallback), so total-only ledger events —
89+
// {tokens:{total:N}} with no input/output — are counted instead of dropped.
90+
const { billableOutput, totalTokens } = foldAmpTotal(input, output, cacheRead, cacheWrite, numberField(tokens, 'total') || 0)
8491
if (totalTokens <= 0) {
8592
continue
8693
}
@@ -91,7 +98,7 @@ async function parseAmpSessionFile(
9198

9299
const metrics: Partial<MetricBag> = {
93100
tokensInput: (input + cacheRead + cacheWrite) || undefined,
94-
tokensOutput: output || undefined,
101+
tokensOutput: billableOutput || undefined,
95102
tokensCachedInput: (cacheRead + cacheWrite) || undefined,
96103
tokensCacheReadInput: cacheRead || undefined,
97104
tokensCacheCreationInput: cacheWrite || undefined,
@@ -142,6 +149,109 @@ async function parseAmpSessionFile(
142149

143150
// ── Amp-specific helpers ──
144151

152+
// ccusage apply_total_token_fallback: when an explicit grand total exceeds the sum
153+
// of the itemized token parts, attribute the shortfall to billable output (and the
154+
// grand total). It never reduces the parts sum. Returns codetime's billable output
155+
// (reasoning/extra folded in) and the reconciled grand total.
156+
function foldAmpTotal(
157+
input: number,
158+
output: number,
159+
cacheRead: number,
160+
cacheWrite: number,
161+
explicitTotal: number,
162+
): { billableOutput: number, totalTokens: number } {
163+
const partsSum = input + output + cacheRead + cacheWrite
164+
const missing = Math.max(0, explicitTotal - partsSum)
165+
return { billableOutput: output + missing, totalTokens: partsSum + missing }
166+
}
167+
168+
// Fallback usage path for Amp threads with no usage ledger: read each assistant
169+
// message's own `usage` object (model/timestamp/inputTokens/outputTokens/
170+
// cacheCreationInputTokens/cacheReadInputTokens/totalTokens). Mirrors ccusage
171+
// parse_message_usage.
172+
function collectAmpMessageUsage(
173+
state: SessionParserState,
174+
messages: Record<string, unknown>[],
175+
sessionId: string,
176+
threadId: string,
177+
_options: Record<string, unknown> & { _: string[] },
178+
): void {
179+
const rows: Array<{
180+
ts: string
181+
model: string | undefined
182+
input: number
183+
billableOutput: number
184+
cacheRead: number
185+
cacheWrite: number
186+
totalTokens: number
187+
}> = []
188+
for (const message of messages) {
189+
if (stringField(message, 'role') !== 'assistant') {
190+
continue
191+
}
192+
const usage = objectField(message, 'usage')
193+
if (Object.keys(usage).length === 0) {
194+
continue
195+
}
196+
const ts = timestampFrom(stringField(usage, 'timestamp')) || timestampFrom(stringField(message, 'timestamp'))
197+
if (!ts) {
198+
continue
199+
}
200+
const model = stringField(usage, 'model') || stringField(message, 'model') || undefined
201+
const input = numberField(usage, 'inputTokens') || 0
202+
const output = numberField(usage, 'outputTokens') || 0
203+
const cacheWrite = numberField(usage, 'cacheCreationInputTokens') || 0
204+
const cacheRead = numberField(usage, 'cacheReadInputTokens') || 0
205+
const { billableOutput, totalTokens } = foldAmpTotal(input, output, cacheRead, cacheWrite, numberField(usage, 'totalTokens') || 0)
206+
if (totalTokens <= 0) {
207+
continue
208+
}
209+
rows.push({ ts, model, input, billableOutput, cacheRead, cacheWrite, totalTokens })
210+
}
211+
if (rows.length === 0) {
212+
return
213+
}
214+
rows.sort((a, b) => a.ts.localeCompare(b.ts))
215+
state.ensureSessionStarted(rows[0].ts, 0)
216+
let lastTs = rows[0].ts
217+
for (const [index, r] of rows.entries()) {
218+
lastTs = r.ts
219+
state.push(
220+
baseAmpEvent({
221+
ts: r.ts,
222+
type: 'model.usage',
223+
sessionId,
224+
model: r.model,
225+
confidence: 'exact',
226+
metrics: {
227+
tokensInput: (r.input + r.cacheRead + r.cacheWrite) || undefined,
228+
tokensOutput: r.billableOutput || undefined,
229+
tokensCachedInput: (r.cacheRead + r.cacheWrite) || undefined,
230+
tokensCacheReadInput: r.cacheRead || undefined,
231+
tokensCacheCreationInput: r.cacheWrite || undefined,
232+
tokensTotal: r.totalTokens,
233+
modelCalls: 1,
234+
},
235+
refs: stringRefs({ threadId }),
236+
}),
237+
index + 1,
238+
'messages',
239+
'model.usage',
240+
)
241+
}
242+
state.push(
243+
baseAmpEvent({
244+
ts: lastTs,
245+
type: 'session.ended',
246+
sessionId,
247+
confidence: 'derived',
248+
}),
249+
rows.length,
250+
'messages',
251+
'session',
252+
)
253+
}
254+
145255
function baseAmpEvent(
146256
event: Omit<CanonicalEvent, 'schemaVersion' | 'source' | 'agent' | 'workspaceId'>,
147257
): CanonicalEvent {

packages/cli/test/amp.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { CanonicalEvent } from '@codetime/shared'
2+
import assert from 'node:assert/strict'
3+
import { mkdtemp, writeFile } from 'node:fs/promises'
4+
import { tmpdir } from 'node:os'
5+
import path from 'node:path'
6+
// eslint-disable-next-line test/no-import-node-test -- This repo uses node:test as the runner.
7+
import { test } from 'node:test'
8+
import { createAmpAdapter } from '../src/adapters/amp.ts'
9+
10+
const adapter = createAmpAdapter()
11+
12+
async function parse(thread: unknown): Promise<CanonicalEvent[]> {
13+
const dir = await mkdtemp(path.join(tmpdir(), 'amp-'))
14+
const file = path.join(dir, 'T-x.json')
15+
await writeFile(file, JSON.stringify(thread), 'utf8')
16+
return adapter.parseSessionFile!(file, { _: [] })
17+
}
18+
19+
function usageEvents(events: CanonicalEvent[]): CanonicalEvent[] {
20+
return events.filter(e => e.type === 'model.usage')
21+
}
22+
23+
test('parity: ccusage amp falls_back_to_total_tokens_when_amp_parts_are_missing', async () => {
24+
// A ledger event carrying only tokens.total (no input/output) must still be
25+
// counted, with the total folded into billable output. ccusage asserts
26+
// output_tokens == 345 (adapter/amp/parser.rs falls_back_to_total_tokens_...).
27+
const usages = usageEvents(await parse({
28+
id: 'T-x',
29+
usageLedger: {
30+
events: [
31+
{ id: 'event-a', timestamp: '2026-01-02T00:00:00.000Z', model: 'gpt-5', tokens: { total: 345 } },
32+
],
33+
},
34+
}))
35+
36+
assert.equal(usages.length, 1)
37+
assert.equal(usages[0].metrics?.tokensOutput, 345)
38+
assert.equal(usages[0].metrics?.tokensTotal, 345)
39+
})
40+
41+
test('parity: ccusage amp ledger folds the shortfall of an explicit total into output', async () => {
42+
// {input:10, output:20, total:100} → ccusage counts 100 total (extra 70 folded
43+
// into billable output); the raw parts alone would be only 30.
44+
const usages = usageEvents(await parse({
45+
id: 'T-x',
46+
usageLedger: {
47+
events: [
48+
{ id: 'event-a', timestamp: '2026-01-02T00:00:00.000Z', model: 'gpt-5', tokens: { input: 10, output: 20, total: 100 } },
49+
],
50+
},
51+
}))
52+
53+
assert.equal(usages.length, 1)
54+
assert.equal(usages[0].metrics?.tokensInput, 10)
55+
assert.equal(usages[0].metrics?.tokensOutput, 90) // 20 + (100 - 30) folded
56+
assert.equal(usages[0].metrics?.tokensTotal, 100)
57+
})
58+
59+
test('parity: ccusage amp reads_usage_from_messages_when_ledger_is_missing', async () => {
60+
// No usageLedger → fall back to per-assistant-message usage. ccusage asserts
61+
// output_tokens == 178 with cache 986/11372 (parser.rs reads_usage_from_messages_...).
62+
const usages = usageEvents(await parse({
63+
id: 'T-x',
64+
messages: [
65+
{
66+
role: 'assistant',
67+
usage: {
68+
model: 'claude-haiku-4-5-20251001',
69+
inputTokens: 10,
70+
outputTokens: 178,
71+
cacheCreationInputTokens: 986,
72+
cacheReadInputTokens: 11_372,
73+
timestamp: '2026-01-19T11:42:10.652Z',
74+
},
75+
},
76+
],
77+
}))
78+
79+
assert.equal(usages.length, 1)
80+
assert.equal(usages[0].model, 'claude-haiku-4-5-20251001')
81+
assert.equal(usages[0].metrics?.tokensOutput, 178)
82+
// cache-inclusive input: 10 + 11372 (read) + 986 (write).
83+
assert.equal(usages[0].metrics?.tokensInput, 10 + 11_372 + 986)
84+
assert.equal(usages[0].metrics?.tokensCacheReadInput, 11_372)
85+
assert.equal(usages[0].metrics?.tokensCacheCreationInput, 986)
86+
})
87+
88+
test('parity: ccusage amp empty_usage_ledger_falls_back_to_message_usage', async () => {
89+
// An empty/malformed usageLedger must not suppress the message-usage fallback.
90+
const usages = usageEvents(await parse({
91+
id: 'T-x',
92+
usageLedger: {},
93+
messages: [
94+
{ role: 'assistant', usage: { model: 'gpt-5', outputTokens: 178, timestamp: '2026-01-19T11:42:10.652Z' } },
95+
],
96+
}))
97+
98+
assert.equal(usages.length, 1)
99+
assert.equal(usages[0].metrics?.tokensOutput, 178)
100+
})
101+
102+
test('amp still parses a normal ledger event unchanged', async () => {
103+
const usages = usageEvents(await parse({
104+
id: 'T-x',
105+
usageLedger: {
106+
events: [
107+
{ id: 'e', timestamp: '2026-01-02T00:00:00.000Z', model: 'gpt-5', tokens: { input: 100, output: 50 } },
108+
],
109+
},
110+
}))
111+
112+
assert.equal(usages.length, 1)
113+
assert.equal(usages[0].metrics?.tokensInput, 100)
114+
assert.equal(usages[0].metrics?.tokensOutput, 50)
115+
assert.equal(usages[0].metrics?.tokensTotal, 150)
116+
})

0 commit comments

Comments
 (0)