@@ -54,7 +54,7 @@ use serde::{Deserialize, Serialize};
5454use crate :: blobs;
5555use crate :: capture:: { Capture , SessionKey , ToolCallContent , TurnContent } ;
5656use crate :: error:: { Error , Result } ;
57- use crate :: model:: { Mode , Role , Source , ToolStatus , WorkspaceMode } ;
57+ use crate :: model:: { Mode , Role , Source , TokenTotals , ToolStatus , WorkspaceMode } ;
5858use crate :: store:: Store ;
5959use crate :: tools:: { canonical_name, ToolName } ;
6060
@@ -319,6 +319,14 @@ fn import_file(
319319 // matching `tool_result` carries just the id. Remembered per file so the
320320 // result's upsert does not downgrade the stored name to `Other`.
321321 let mut call_meta: HashMap < String , ( ToolName , i64 ) > = HashMap :: new ( ) ;
322+ // Usage, deduplicated by request — see `read_usage`. Accumulated for the
323+ // whole file and written once at the end, because the total is only true
324+ // once the file has been read.
325+ let mut usage_by_request: HashMap < String , TokenTotals > = HashMap :: new ( ) ;
326+ // `read_turn` only sees `message.model` on lines that carry text, so a
327+ // transcript whose assistant lines are all tool calls would never name its
328+ // model. Taken from any line that has one.
329+ let mut model_seen: Option < String > = None ;
322330 let no_locations = serde_json:: json!( [ ] ) ;
323331
324332 {
@@ -355,6 +363,34 @@ fn import_file(
355363 continue ;
356364 }
357365
366+ // Read before the "nothing usable here" filter below: a tool-only
367+ // assistant line produces no turn and no tool_result, and dropping
368+ // it would silently lose the usage of every tool-calling request.
369+ if let Some ( line_usage) = read_usage ( & value) {
370+ let slot = usage_by_request. entry ( line_usage. key ) . or_default ( ) ;
371+ // Per-field max rather than overwrite: two copies of one
372+ // request disagree when the first was written mid-stream, and
373+ // the finished one is the larger.
374+ slot. input_tokens = slot. input_tokens . max ( line_usage. totals . input_tokens ) ;
375+ slot. output_tokens = slot. output_tokens . max ( line_usage. totals . output_tokens ) ;
376+ slot. cache_creation_tokens =
377+ slot. cache_creation_tokens . max ( line_usage. totals . cache_creation_tokens ) ;
378+ slot. cache_read_tokens =
379+ slot. cache_read_tokens . max ( line_usage. totals . cache_read_tokens ) ;
380+ }
381+ // Assistant lines only: the model is a property of what answered,
382+ // and a user line that happens to carry one is echoing the client's
383+ // request rather than reporting what ran.
384+ if model_seen. is_none ( )
385+ && value. get ( "type" ) . and_then ( serde_json:: Value :: as_str) == Some ( "assistant" )
386+ {
387+ model_seen = value
388+ . get ( "message" )
389+ . and_then ( |m| m. get ( "model" ) )
390+ . and_then ( serde_json:: Value :: as_str)
391+ . map ( str:: to_string) ;
392+ }
393+
358394 let created_at = line_timestamp ( & value) ;
359395 let turn = read_turn ( & value) . filter ( |t| !is_envelope ( t) ) ;
360396 let tool_uses = read_tool_uses ( & value) ;
@@ -504,6 +540,30 @@ fn import_file(
504540
505541 if let Some ( id) = & session_id {
506542 outcome. sessions_imported += imported_here;
543+
544+ // The whole-file total, deduplicated by request and written as a
545+ // replace. After an I/O error this covers only the prefix that was
546+ // read, which is correct: the progress marker below is not advanced, so
547+ // the next pass recomputes the file from the top.
548+ let mut usage = TokenTotals :: default ( ) ;
549+ for request in usage_by_request. values ( ) {
550+ usage. input_tokens = usage. input_tokens . saturating_add ( request. input_tokens ) ;
551+ usage. output_tokens = usage. output_tokens . saturating_add ( request. output_tokens ) ;
552+ usage. cache_creation_tokens =
553+ usage. cache_creation_tokens . saturating_add ( request. cache_creation_tokens ) ;
554+ usage. cache_read_tokens =
555+ usage. cache_read_tokens . saturating_add ( request. cache_read_tokens ) ;
556+ }
557+ if usage != TokenTotals :: default ( ) {
558+ store. replace_usage_totals ( id, & usage) ?;
559+ }
560+ // The model, when the Session did not already have one — `COALESCE` in
561+ // the upsert takes the new value and leaves an existing one alone.
562+ if let Some ( model) = & model_seen {
563+ let mut capture = Capture :: new ( store, mode) ;
564+ capture. ensure_session ( & session_key, None , Some ( model. as_str ( ) ) , None , None ) ?;
565+ }
566+
507567 // Live capture stamped "now" at first sighting; the transcript knows
508568 // when the conversation really began. Without this, a year of imported
509569 // history all dates from the day the import ran.
@@ -522,6 +582,47 @@ fn import_file(
522582 Ok ( ( ) )
523583}
524584
585+ /// The usage one assistant line reports, with the request it belongs to.
586+ struct UsageLine {
587+ key : String ,
588+ totals : TokenTotals ,
589+ }
590+
591+ /// Read `message.usage` off an assistant line.
592+ ///
593+ /// The transcript writes the same logical assistant message more than once — a
594+ /// streaming record, then its rewrite — and every copy repeats the same usage
595+ /// block. On a real transcript here, 18 usage lines collapsed to 8 requests:
596+ /// summing lines instead of requests over-counts by 2.2x. `requestId` is the
597+ /// key that collapses them, falling back to the message id and then the line's
598+ /// own uuid so a line without one is at least counted once rather than merged
599+ /// into a neighbour.
600+ fn read_usage ( value : & serde_json:: Value ) -> Option < UsageLine > {
601+ let message = value. get ( "message" ) ?;
602+ let usage = message. get ( "usage" ) ?;
603+ let key = value
604+ . get ( "requestId" )
605+ . and_then ( serde_json:: Value :: as_str)
606+ . or_else ( || message. get ( "id" ) . and_then ( serde_json:: Value :: as_str) )
607+ . or_else ( || value. get ( "uuid" ) . and_then ( serde_json:: Value :: as_str) ) ?
608+ . to_string ( ) ;
609+
610+ let field = |name : & str | usage. get ( name) . and_then ( serde_json:: Value :: as_u64) . unwrap_or ( 0 ) ;
611+ let totals = TokenTotals {
612+ input_tokens : field ( "input_tokens" ) ,
613+ output_tokens : field ( "output_tokens" ) ,
614+ cache_creation_tokens : field ( "cache_creation_input_tokens" ) ,
615+ cache_read_tokens : field ( "cache_read_input_tokens" ) ,
616+ ..Default :: default ( )
617+ } ;
618+ // A usage block of all zeros carries no information and would otherwise
619+ // occupy a request slot, so an empty total stays empty.
620+ if totals == TokenTotals :: default ( ) {
621+ return None ;
622+ }
623+ Some ( UsageLine { key, totals } )
624+ }
625+
525626/// One usable line of a transcript.
526627struct ImportedTurn {
527628 role : Role ,
0 commit comments