@@ -66,7 +66,21 @@ func computeBillingSnapshot(fullEntries, turnEntries []TranscriptEntry) map[stri
6666 staticPieces := staticPrefixPieces (fullEntries )
6767 skillBodyNames := skillBodyNameBySHA (fullEntries )
6868 toolNames := toolUseNames (fullEntries )
69- counts := countNewEvents (turnEntries , skillBodyNames , toolNames )
69+
70+ // Counts: one event per NEW conversation piece this turn, derived from
71+ // the SAME layout (keys, sizing, bucketing) billing re-bills every turn.
72+ // A separate scanner here once bucketed prompts by estimate while billing
73+ // bucketed by measured tokens, so counts landed in one bucket and tokens
74+ // in another. Assistant pieces are excluded: their input-lane keys would
75+ // double-count tool calls already counted via tool_result, and their
76+ // events are counted on the output side by attributeOutput under the
77+ // output item keys.
78+ counts := map [billingKey ]int {}
79+ for _ , p := range conversationPieces (turnEntries , skillBodyNames , toolNames ) {
80+ if p .key .kind == kindUsage && ! p .exact {
81+ counts [p .key ]++
82+ }
83+ }
7084
7185 acc := map [billingKey ]* billingTier {}
7286 totals := billingTier {}
@@ -83,7 +97,7 @@ func computeBillingSnapshot(fullEntries, turnEntries []TranscriptEntry) map[stri
8397 pieces = reconcileToUsage (pieces , float64 (call .read + call .write + call .fresh ))
8498 cutByPosition (pieces , float64 (call .read ), float64 (call .write ), acc )
8599
86- attributeOutput (fullEntries [call .entryIdx :call .entryEnd ], acc )
100+ attributeOutput (fullEntries [call .entryIdx :call .entryEnd ], acc , counts )
87101
88102 totals .cacheRead += float64 (call .read )
89103 totals .cacheCreation += float64 (call .write )
@@ -399,13 +413,13 @@ func toolLane(name string) (string, string) {
399413 }
400414}
401415
402- // reconcileToUsage makes Σ pieces == total exactly . Overshoot shrinks the
403- // estimated pieces first (usage-derived ones are normally already exact);
404- // if the usage -derived pieces alone still exceed the measured total — the
405- // request dropped content we can't see (compaction we failed to detect,
406- // context editing) — they are scaled down too: the per-call exactness
407- // contract outranks per-piece exactness. Undershoot appends the explicit
408- // `unattributed` tail piece .
416+ // reconcileToUsage makes Σ pieces == total. Overshoot shrinks only the
417+ // estimated pieces (usage-derived ones are exact); undershoot appends the
418+ // explicit `unattributed` tail piece. Usage -derived pieces are NEVER
419+ // scaled, deliberately: if they alone exceed the measured prompt, the
420+ // request dropped content we failed to detect (a truncation mechanism we
421+ // don't parse yet), and the resulting Σ lanes > usage discrepancy is the
422+ // signal that finds that bug — smearing it away would hide it .
409423func reconcileToUsage (pieces []billingPiece , total float64 ) []billingPiece {
410424 sum , estSum := 0.0 , 0.0
411425 for _ , p := range pieces {
@@ -414,27 +428,16 @@ func reconcileToUsage(pieces []billingPiece, total float64) []billingPiece {
414428 estSum += p .tokens
415429 }
416430 }
417- exactSum := sum - estSum
418431 switch {
419- case sum > total :
420- if estSum > 0 {
421- target := total - exactSum
422- if target < 0 {
423- target = 0
424- }
425- scale := target / estSum
426- for i := range pieces {
427- if ! pieces [i ].exact {
428- pieces [i ].tokens *= scale
429- }
430- }
432+ case sum > total && estSum > 0 :
433+ target := total - (sum - estSum )
434+ if target < 0 {
435+ target = 0
431436 }
432- if exactSum > total {
433- scale := total / exactSum
434- for i := range pieces {
435- if pieces [i ].exact {
436- pieces [i ].tokens *= scale
437- }
437+ scale := target / estSum
438+ for i := range pieces {
439+ if ! pieces [i ].exact {
440+ pieces [i ].tokens *= scale
438441 }
439442 }
440443 case sum < total :
@@ -466,8 +469,12 @@ func cutByPosition(pieces []billingPiece, read, write float64, acc map[billingKe
466469
467470// attributeOutput books the call's own blocks against output. callEntries is
468471// the contiguous span of the call's entries, so per-block attributed shares
469- // sum to the call's usage.output_tokens by construction.
470- func attributeOutput (callEntries []TranscriptEntry , acc map [billingKey ]* billingTier ) {
472+ // sum to the call's usage.output_tokens by construction. Each block also
473+ // bumps counts under the same key, so output items carry true event counts
474+ // (blocks emitted, tool calls made) keyed identically to their tokens.
475+ func attributeOutput (callEntries []TranscriptEntry , acc map [billingKey ]* billingTier ,
476+ counts map [billingKey ]int ) {
477+
471478 parsed := ParseAssistantMessages (callEntries )
472479 DeduplicateUsage (parsed )
473480 for _ , p := range parsed {
@@ -484,63 +491,10 @@ func attributeOutput(callEntries []TranscriptEntry, acc map[billingKey]*billingT
484491 continue
485492 }
486493 tierFor (acc , key ).output += float64 (p .AttributedOutputTokens )
494+ counts [key ]++
487495 }
488496}
489497
490- // countNewEvents returns the number of NEW events this turn per usage key:
491- // prompts per bucket, tool calls per tool/server, files per ext, skill loads
492- // per skill. Additive across traces (each event counted once, in its turn),
493- // so plain SUM yields true counts — the same split rule as everywhere else.
494- func countNewEvents (turnEntries []TranscriptEntry , skillBodyNames map [string ]string ,
495- toolNames map [string ]string ) map [billingKey ]int {
496-
497- counts := map [billingKey ]int {}
498- bump := func (lane , entity string ) {
499- counts [billingKey {lane , entity , kindUsage }]++
500- }
501-
502- for _ , e := range turnEntries {
503- switch e .Type {
504- case "user" :
505- if e .Message == nil || e .IsCompactSummary {
506- continue
507- }
508- for _ , c := range e .Message .Content {
509- switch c .Type {
510- case "text" :
511- if _ , ok := skillBodyNames [sha256hex (c .Text )]; ok {
512- continue // loads counted via buildLoadedSkillBodies below
513- }
514- bump ("user_prompts" , promptBucket (tokEstimateAs (c .Text , "user_prompt" )))
515- case "tool_result" :
516- lane , entity := toolLane (toolNames [c .ToolUseID ])
517- bump (lane , entity )
518- }
519- }
520- case "attachment" :
521- if e .Attachment == nil || e .Attachment .Type != "file" {
522- continue
523- }
524- var w struct {
525- File struct {
526- Path string `json:"path,omitempty"`
527- } `json:"file"`
528- }
529- if json .Unmarshal (e .Attachment .Content , & w ) == nil {
530- ext := strings .ToLower (filepath .Ext (w .File .Path ))
531- if ext == "" {
532- ext = "other"
533- }
534- bump ("file_attachments" , ext )
535- }
536- }
537- }
538- for _ , l := range buildLoadedSkillBodies (turnEntries ) {
539- bump ("skills" , l .Name )
540- }
541- return counts
542- }
543-
544498func tierFor (acc map [billingKey ]* billingTier , key billingKey ) * billingTier {
545499 t , ok := acc [key ]
546500 if ! ok {
@@ -636,7 +590,12 @@ func renderBillingSnapshot(callCount int, totals billingTier,
636590 }
637591
638592 lanes := map [string ]interface {}{}
593+ laneSum := billingTier {}
639594 for lane , t := range laneTiers {
595+ laneSum .cacheRead += t .cacheRead
596+ laneSum .cacheCreation += t .cacheCreation
597+ laneSum .fresh += t .fresh
598+ laneSum .output += t .output
640599 obj := tierFields (t )
641600 if items := laneItems [lane ]; len (items ) > 0 {
642601 sort .Slice (items , func (i , j int ) bool {
@@ -660,10 +619,42 @@ func renderBillingSnapshot(callCount int, totals billingTier,
660619 "input" : round (totals .fresh ),
661620 "output" : round (totals .output ),
662621 },
663- "lanes" : lanes ,
622+ "lanes" : lanes ,
623+ "reconciliation" : reconciliation (laneSum , totals ),
664624 }
665625}
666626
627+ // reconciliation reports Σ lanes minus usage per tier column. Healthy
628+ // attribution reconciles to ~0 (the unattributed lane absorbs undershoot);
629+ // a non-zero delta means the layout disagrees with what the API billed —
630+ // usually a truncation mechanism we don't detect yet. It is emitted on
631+ // every trace so inconsistencies are monitorable instead of latent.
632+ func reconciliation (laneSum , totals billingTier ) map [string ]interface {} {
633+ delta := func (got , want float64 ) int {
634+ d := got - want
635+ if d < 0 {
636+ return - round (- d )
637+ }
638+ return round (d )
639+ }
640+ deltas := map [string ]int {
641+ "input_delta" : delta (laneSum .fresh , totals .fresh ),
642+ "cache_read_delta" : delta (laneSum .cacheRead , totals .cacheRead ),
643+ "cache_creation_delta" : delta (laneSum .cacheCreation , totals .cacheCreation ),
644+ "output_delta" : delta (laneSum .output , totals .output ),
645+ }
646+ consistent := true
647+ out := map [string ]interface {}{}
648+ for k , v := range deltas {
649+ out [k ] = v
650+ if v != 0 {
651+ consistent = false
652+ }
653+ }
654+ out ["consistent" ] = consistent
655+ return out
656+ }
657+
667658func round (f float64 ) int { return int (f + 0.5 ) }
668659
669660func minF (a , b float64 ) float64 {
0 commit comments