@@ -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+
145255function baseAmpEvent (
146256 event : Omit < CanonicalEvent , 'schemaVersion' | 'source' | 'agent' | 'workspaceId' > ,
147257) : CanonicalEvent {
0 commit comments