Skip to content

Commit 2d918f7

Browse files
committed
fix(cost): scope the subagent remainder bucket to new tokens per turn
CalculateUsageWithCost's remainder bucket exists to price the subagent tokens a ModelUsageCalculator's main-transcript-only buckets never see. It derived that shortfall by flattening flat.SubagentTokens, which per the SubagentAwareExtractor contract is a cumulative-since-session-start snapshot, not a per-window delta like flat's own scalars and the per-model buckets. Callers sum the returned buckets and cost across turns (accumulateModelUsage / accumulateTokenUsage), so every turn re-attributed the whole cumulative subagent total on top of every earlier turn's: state.TokenUsage.CostUSD, state.CheckpointTokenUsage.CostUSD and state.ModelUsage all grew without bound with the turn count, even when the subagent did nothing after turn 1, and the inflated per-model aggregate persisted into checkpoint metadata. Thread the previously-accounted snapshot into CalculateUsageWithCost and subtract it in remainderBucket, so the remainder is a true increment. The turn-end hook sources it from state.TokenUsage.SubagentTokens, which accumulateTokenUsage already keeps at the latest snapshot. One session-wide baseline is correct for both aggregates: the checkpoint window is a suffix of the same additive stream and gets its scope from resetCheckpointWindow, so baselining on SubagentTokensBaseline instead would reproduce the bug one scope smaller. Condensation passes nil because it supplies no subagents dir and so never sees a subtree. Also carry CacheCreation1hTokens, the subset of CacheCreationTokens billed at 2x input instead of 1.25x, through every accounting helper that dropped it: flattenTokenUsage (remainder shortfall and EstimateCost's flat fallback), remainderBucket, SubtractTokenUsage, accumulateTokenUsage, the checkpoint summary and token-command aggregators, and the two subagent aggregation loops. Anywhere it was dropped, 1h cache writes silently priced at the 5-minute rate. Entire-Checkpoint: 01KZPB94Y1C6A60H13ST3HTDCH
1 parent 6795377 commit 2d918f7

13 files changed

Lines changed: 820 additions & 41 deletions

cmd/entire/cli/agent/claudecode/transcript.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,10 @@ func (c *ClaudeCodeAgent) CalculateTotalTokenUsage(transcriptData []byte, startL
535535
}
536536
subagentUsage.InputTokens += agentUsage.InputTokens
537537
subagentUsage.CacheCreationTokens += agentUsage.CacheCreationTokens
538+
// CacheCreation1hTokens is the subset of CacheCreationTokens written with
539+
// a 1-hour TTL, billed at 2x input instead of 1.25x. Omitting it made
540+
// every subagent's cache writes look 5-minute, undercounting cost.
541+
subagentUsage.CacheCreation1hTokens += agentUsage.CacheCreation1hTokens
538542
subagentUsage.CacheReadTokens += agentUsage.CacheReadTokens
539543
subagentUsage.OutputTokens += agentUsage.OutputTokens
540544
subagentUsage.APICallCount += agentUsage.APICallCount

cmd/entire/cli/agent/factoryaidroid/transcript.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,13 @@ func CalculateTotalTokenUsageFromBytes(data []byte, startLine int, subagentsDir
401401
}
402402
subagentUsage.InputTokens += agentUsage.InputTokens
403403
subagentUsage.CacheCreationTokens += agentUsage.CacheCreationTokens
404+
// Carried for parity with Claude Code's subagent aggregation: this is
405+
// the subset of CacheCreationTokens written with a 1-hour TTL, billed at
406+
// 2x input instead of 1.25x. Droid's own usage parser does not populate
407+
// it yet (its transcript exposes no 1h field), so today this is a no-op —
408+
// it exists so adding that parsing later cannot silently drop the
409+
// premium on the subagent side.
410+
subagentUsage.CacheCreation1hTokens += agentUsage.CacheCreation1hTokens
404411
subagentUsage.CacheReadTokens += agentUsage.CacheReadTokens
405412
subagentUsage.OutputTokens += agentUsage.OutputTokens
406413
subagentUsage.APICallCount += agentUsage.APICallCount

cmd/entire/cli/agent/token_usage.go

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,23 @@ func flatTokenUsage(ag Agent, transcriptData []byte, fromOffset int, subagentsDi
6060
// (CostSourceMixed when priced and unpriced-with-tokens buckets coexist).
6161
// Returns the flat usage (cost fields populated) and the buckets; (nil, nil, nil)
6262
// when the agent produces no usage.
63-
func CalculateUsageWithCost(ag Agent, transcriptData []byte, fromOffset int, subagentsDir string, table *pricing.Table, fallbackModel string, disableEstimation bool) (*types.TokenUsage, []types.ModelUsage, error) {
63+
//
64+
// SCOPING CONTRACT — the returned buckets and the returned flat usage's CostUSD
65+
// are DELTAS scoped to this call's window, safe for a caller to sum across turns.
66+
// The returned flat usage's SubagentTokens subtree is NOT a delta: per the
67+
// SubagentAwareExtractor contract it stays the cumulative-since-session-start
68+
// snapshot the extractor produced, so a caller must replace (never add) it.
69+
//
70+
// accountedSubagentTokens is what makes the cost half of that contract hold. It
71+
// is the cumulative subagent snapshot a previous call ALREADY attributed cost to
72+
// (for the live turn-end path: state.TokenUsage.SubagentTokens, which
73+
// accumulateTokenUsage keeps at the latest snapshot). remainderBucket subtracts
74+
// it so the subagent remainder covers only the NEW subagent tokens since then.
75+
// Pass nil for the first call of a session, or when subagentsDir is empty and no
76+
// subagent subtree can exist. Passing nil on a later call re-attributes the whole
77+
// cumulative subagent total again, which an additive caller then stacks on top of
78+
// every earlier turn's — unbounded inflation of cost and per-model tokens.
79+
func CalculateUsageWithCost(ag Agent, transcriptData []byte, fromOffset int, subagentsDir string, accountedSubagentTokens *types.TokenUsage, table *pricing.Table, fallbackModel string, disableEstimation bool) (*types.TokenUsage, []types.ModelUsage, error) {
6480
fallbackModel = resolveTierFallback(fallbackModel, table)
6581

6682
flat, err := flatTokenUsage(ag, transcriptData, fromOffset, subagentsDir)
@@ -86,7 +102,7 @@ func CalculateUsageWithCost(ag Agent, transcriptData []byte, fromOffset int, sub
86102
// remainder bucket under fallbackModel so the pricing pass either estimates
87103
// it (priceable fallback) or leaves it unpriced (unpriceable fallback), in
88104
// which case foldBucketCost's mixed rule marks coverage honestly.
89-
if rem, ok := remainderBucket(flat, buckets, fallbackModel); ok {
105+
if rem, ok := remainderBucket(flat, accountedSubagentTokens, buckets, fallbackModel); ok {
90106
buckets = append(buckets, rem)
91107
}
92108

@@ -309,55 +325,106 @@ func modelUsageBuckets(ag Agent, transcriptData []byte, fromOffset int, flat *ty
309325
// 100k main + 500k subagent tokens would price only the 100k. Here the 500k
310326
// shortfall becomes a remainder bucket under fallbackModel.
311327
//
328+
// SCOPING — the two sides of that subtraction are scoped differently, and
329+
// reconciling them is this function's second job. flat's own scalar fields and
330+
// every per-model bucket are DELTAS over the caller's transcript window (both
331+
// derive from the fromOffset slice), but flat.SubagentTokens is a
332+
// cumulative-since-session-start snapshot per the SubagentAwareExtractor
333+
// contract: agent IDs come from the full transcript and each subagent transcript
334+
// is re-read from line 0 on every call. Flattening the raw subtree into the
335+
// shortfall therefore re-attributed the ENTIRE cumulative subagent total on every
336+
// turn. Callers sum these buckets and this call's cost across turns
337+
// (strategy.accumulateModelUsage / accumulateTokenUsage), so a session with
338+
// subagents inflated its cost, its per-model token counts, and its persisted
339+
// checkpoint metadata without bound as it went on — even with no new subagent
340+
// activity after turn 1.
341+
//
342+
// accountedSubagentTokens is the cumulative snapshot already attributed by an
343+
// earlier call; SubtractTokenUsage rescopes the subtree to just the increment
344+
// since then, which makes the shortfall a true window delta and the sum across
345+
// turns converge on the real cumulative total. A nil baseline means "nothing
346+
// attributed yet" and keeps the full snapshot (correct for the first call, and
347+
// for callers that pass no subagentsDir and so never see a subtree at all).
348+
// Rescoping deliberately uses the SESSION-WIDE previous snapshot, not a
349+
// condensation-window baseline like SessionState.SubagentTokensBaseline: the
350+
// per-checkpoint aggregate is a suffix of the same additive stream, so it gets
351+
// its correct window from resetCheckpointWindow zeroing the accumulator, and
352+
// baselining on the window start instead would re-add the window's whole
353+
// subagent total on every turn inside it — the same bug, one scope smaller. A
354+
// turn whose checkpoint is skipped never accumulates, leaving the baseline where
355+
// it was, so the next attributed turn picks up the whole unaccounted increment.
356+
//
312357
// Each field is clamped at 0 (a bucket may legitimately exceed the flat total on
313358
// an individual field). The bool is false when no billable token shortfall
314-
// exists: the fallback (single-bucket) path with no subagents and any
315-
// ModelUsageCalculator whose buckets sum to the flat total both yield a zero
359+
// exists: the fallback (single-bucket) path with no subagents, any
360+
// ModelUsageCalculator whose buckets sum to the flat total, and a turn that added
361+
// no subagent tokens to an already-accounted snapshot all yield a zero
316362
// remainder. Cost fields are left nil so the pricing pass estimates the
317363
// remainder when fallbackModel is priceable, or leaves it unpriced otherwise.
318-
func remainderBucket(flat *types.TokenUsage, buckets []types.ModelUsage, fallbackModel string) (types.ModelUsage, bool) {
364+
func remainderBucket(flat, accountedSubagentTokens *types.TokenUsage, buckets []types.ModelUsage, fallbackModel string) (types.ModelUsage, bool) {
319365
var sum types.TokenUsage
320366
for i := range buckets {
321367
b := buckets[i].TokenUsage
322368
sum.InputTokens += b.InputTokens
323369
sum.CacheCreationTokens += b.CacheCreationTokens
370+
sum.CacheCreation1hTokens += b.CacheCreation1hTokens
324371
sum.CacheReadTokens += b.CacheReadTokens
325372
sum.OutputTokens += b.OutputTokens
326373
sum.APICallCount += b.APICallCount
327374
}
328-
flatTotal := flattenTokenUsage(flat)
375+
// Rescope the cumulative subagent subtree to this window's increment before
376+
// flattening. Copying flat and swapping in the rescoped subtree leaves the
377+
// caller's flat untouched (its SubagentTokens must stay the cumulative
378+
// snapshot downstream) and cannot alias it: SubtractTokenUsage allocates.
379+
scoped := *flat
380+
scoped.SubagentTokens = types.SubtractTokenUsage(flat.SubagentTokens, accountedSubagentTokens)
381+
flatTotal := flattenTokenUsage(&scoped)
329382
short := types.TokenUsage{
330383
InputTokens: clampNonNegative(flatTotal.InputTokens - sum.InputTokens),
331384
CacheCreationTokens: clampNonNegative(flatTotal.CacheCreationTokens - sum.CacheCreationTokens),
332-
CacheReadTokens: clampNonNegative(flatTotal.CacheReadTokens - sum.CacheReadTokens),
333-
OutputTokens: clampNonNegative(flatTotal.OutputTokens - sum.OutputTokens),
334-
APICallCount: clampNonNegative(flatTotal.APICallCount - sum.APICallCount),
385+
// CacheCreation1hTokens rides along with CacheCreationTokens (it is a
386+
// subset of it). Dropping it here billed every remainder-attributed cache
387+
// write at the 1.25x 5-minute rate even when the session used the 2x
388+
// 1-hour TTL, silently undercounting cost.
389+
CacheCreation1hTokens: clampNonNegative(flatTotal.CacheCreation1hTokens - sum.CacheCreation1hTokens),
390+
CacheReadTokens: clampNonNegative(flatTotal.CacheReadTokens - sum.CacheReadTokens),
391+
OutputTokens: clampNonNegative(flatTotal.OutputTokens - sum.OutputTokens),
392+
APICallCount: clampNonNegative(flatTotal.APICallCount - sum.APICallCount),
335393
}
336394
if short.InputTokens+short.CacheCreationTokens+short.CacheReadTokens+short.OutputTokens == 0 {
337395
return types.ModelUsage{}, false
338396
}
339397
return types.ModelUsage{Model: fallbackModel, TokenUsage: short}, true
340398
}
341399

342-
// flattenTokenUsage returns u's five scalar token fields summed with every
343-
// nested SubagentTokens subtree, at arbitrary depth. It is nil-safe (nil yields
344-
// a zero usage) and reads only token counts — the returned usage carries no cost
400+
// flattenTokenUsage returns u's scalar token fields summed with every nested
401+
// SubagentTokens subtree, at arbitrary depth. It is nil-safe (nil yields a zero
402+
// usage) and reads only token counts — the returned usage carries no cost
345403
// fields. This is how remainderBucket recovers the true billable total from a
346404
// flat usage whose subagent tokens live in a subtree rather than in the top-level
347405
// scalar fields.
406+
//
407+
// CacheCreation1hTokens is summed like the rest: it is the subset of
408+
// CacheCreationTokens written with a 1-hour TTL and pricing.Estimate bills it at
409+
// 2x input instead of 1.25x. Omitting it (as this function did before) collapsed
410+
// every flattened total onto the 5-minute rate — a silent undercount on both of
411+
// this function's consumers, remainderBucket's shortfall and EstimateCost's
412+
// no-buckets flat fallback.
348413
func flattenTokenUsage(u *types.TokenUsage) types.TokenUsage {
349414
var out types.TokenUsage
350415
if u == nil {
351416
return out
352417
}
353418
out.InputTokens = u.InputTokens
354419
out.CacheCreationTokens = u.CacheCreationTokens
420+
out.CacheCreation1hTokens = u.CacheCreation1hTokens
355421
out.CacheReadTokens = u.CacheReadTokens
356422
out.OutputTokens = u.OutputTokens
357423
out.APICallCount = u.APICallCount
358424
sub := flattenTokenUsage(u.SubagentTokens)
359425
out.InputTokens += sub.InputTokens
360426
out.CacheCreationTokens += sub.CacheCreationTokens
427+
out.CacheCreation1hTokens += sub.CacheCreation1hTokens
361428
out.CacheReadTokens += sub.CacheReadTokens
362429
out.OutputTokens += sub.OutputTokens
363430
out.APICallCount += sub.APICallCount

cmd/entire/cli/agent/token_usage_cost_test.go

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func TestCalculateUsageWithCost_FallbackBucketPriced(t *testing.T) {
7878
t.Parallel()
7979
ag := &fakeTokenCalcAgent{usage: &TokenUsage{InputTokens: 1_000_000}}
8080

81-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "test-a", false)
81+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "test-a", false)
8282
if err != nil {
8383
t.Fatalf("unexpected error: %v", err)
8484
}
@@ -109,7 +109,7 @@ func TestCalculateUsageWithCost_TwoModelsSumEstimated(t *testing.T) {
109109
},
110110
}
111111

112-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "", false)
112+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "", false)
113113
if err != nil {
114114
t.Fatalf("unexpected error: %v", err)
115115
}
@@ -137,7 +137,7 @@ func TestCalculateUsageWithCost_UnknownModelMixed(t *testing.T) {
137137
},
138138
}
139139

140-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "", false)
140+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "", false)
141141
if err != nil {
142142
t.Fatalf("unexpected error: %v", err)
143143
}
@@ -171,7 +171,7 @@ func TestCalculateUsageWithCost_RemainderBucketPriced(t *testing.T) {
171171
},
172172
}
173173

174-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "test-b", false)
174+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "test-b", false)
175175
if err != nil {
176176
t.Fatalf("unexpected error: %v", err)
177177
}
@@ -217,7 +217,7 @@ func TestCalculateUsageWithCost_RemainderBucketInflatedScalar(t *testing.T) {
217217
},
218218
}
219219

220-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "test-b", false)
220+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "test-b", false)
221221
if err != nil {
222222
t.Fatalf("unexpected error: %v", err)
223223
}
@@ -256,7 +256,7 @@ func TestCalculateUsageWithCost_RemainderBucketNestedSubagents(t *testing.T) {
256256
},
257257
}
258258

259-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "test-b", false)
259+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "test-b", false)
260260
if err != nil {
261261
t.Fatalf("unexpected error: %v", err)
262262
}
@@ -290,7 +290,7 @@ func TestCalculateUsageWithCost_SubagentRemainderFallbackNoAliasing(t *testing.T
290290
}}
291291

292292
run := func() ([]types.ModelUsage, *types.TokenUsage) {
293-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "subdir", testTable(t), "test-a", false)
293+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "subdir", nil, testTable(t), "test-a", false)
294294
if err != nil {
295295
t.Fatalf("unexpected error: %v", err)
296296
}
@@ -343,7 +343,7 @@ func TestCalculateUsageWithCost_RemainderUnpriceableFallbackMixed(t *testing.T)
343343
},
344344
}
345345

346-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "who-knows", false)
346+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "who-knows", false)
347347
if err != nil {
348348
t.Fatalf("unexpected error: %v", err)
349349
}
@@ -377,7 +377,7 @@ func TestCalculateUsageWithCost_NoRemainderWhenBucketsSumToFlat(t *testing.T) {
377377
},
378378
}
379379

380-
_, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "test-a", false)
380+
_, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "test-a", false)
381381
if err != nil {
382382
t.Fatalf("unexpected error: %v", err)
383383
}
@@ -396,7 +396,7 @@ func TestCalculateUsageWithCost_ReportedKeptEstimationOff(t *testing.T) {
396396
},
397397
}
398398

399-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "", true /* disableEstimation */)
399+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "", true /* disableEstimation */)
400400
if err != nil {
401401
t.Fatalf("unexpected error: %v", err)
402402
}
@@ -423,7 +423,7 @@ func TestCalculateUsageWithCost_NilTableNoCost(t *testing.T) {
423423
t.Parallel()
424424
ag := &fakeTokenCalcAgent{usage: &TokenUsage{InputTokens: 1_000_000}}
425425

426-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil /* table */, "test-a", false)
426+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, nil /* table */, "test-a", false)
427427
if err != nil {
428428
t.Fatalf("unexpected error: %v", err)
429429
}
@@ -441,7 +441,7 @@ func TestCalculateUsageWithCost_NilTableNoCost(t *testing.T) {
441441
func TestCalculateUsageWithCost_NoUsage(t *testing.T) {
442442
t.Parallel()
443443
// Agent that supports neither TokenCalculator nor ModelUsageCalculator.
444-
flat, buckets, err := CalculateUsageWithCost(&mockBaseAgent{}, nil, 0, "", testTable(t), "test-a", false)
444+
flat, buckets, err := CalculateUsageWithCost(&mockBaseAgent{}, nil, 0, "", nil, testTable(t), "test-a", false)
445445
if err != nil {
446446
t.Fatalf("unexpected error: %v", err)
447447
}
@@ -453,7 +453,7 @@ func TestCalculateUsageWithCost_NoUsage(t *testing.T) {
453453
func TestCalculateUsageWithCost_FlatError(t *testing.T) {
454454
t.Parallel()
455455
ag := &fakeTokenCalcAgent{err: errors.New("boom")}
456-
if _, _, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "test-a", false); err == nil {
456+
if _, _, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "test-a", false); err == nil {
457457
t.Fatal("expected error, got nil")
458458
}
459459
}
@@ -467,7 +467,7 @@ func TestCalculateUsageWithCost_ZeroTokenBucketUnpriced(t *testing.T) {
467467
// checkpoint reads as "no cost data", consistent with the ModelUsage path.
468468
ag := &fakeTokenCalcAgent{usage: &TokenUsage{APICallCount: 5}}
469469

470-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", testTable(t), "test-a", false)
470+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, testTable(t), "test-a", false)
471471
if err != nil {
472472
t.Fatalf("unexpected error: %v", err)
473473
}
@@ -571,7 +571,7 @@ func TestCalculateUsageWithCost_ClaudeFastVariantPricedAtPremium(t *testing.T) {
571571
},
572572
}
573573

574-
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", table, "", false)
574+
flat, buckets, err := CalculateUsageWithCost(ag, nil, 0, "", nil, table, "", false)
575575
if err != nil {
576576
t.Fatalf("unexpected error: %v", err)
577577
}
@@ -602,7 +602,7 @@ func TestRemainderBucket_APICallCountOnlyShortfallNoRemainder(t *testing.T) {
602602
{Model: "test-a", TokenUsage: TokenUsage{InputTokens: 1_000_000, APICallCount: 2}},
603603
}
604604

605-
_, ok := remainderBucket(flat, buckets, "test-a")
605+
_, ok := remainderBucket(flat, nil, buckets, "test-a")
606606
if ok {
607607
t.Fatalf("remainderBucket returned a bucket for an APICallCount-only shortfall, want none")
608608
}
@@ -618,7 +618,7 @@ func TestRemainderBucket_RealTokenShortfallStillReturned(t *testing.T) {
618618
{Model: "test-a", TokenUsage: TokenUsage{InputTokens: 1_000_000, APICallCount: 2}},
619619
}
620620

621-
rem, ok := remainderBucket(flat, buckets, "test-a")
621+
rem, ok := remainderBucket(flat, nil, buckets, "test-a")
622622
if !ok {
623623
t.Fatalf("remainderBucket returned no bucket for a real token shortfall")
624624
}

0 commit comments

Comments
 (0)