fix(agent): stop double-counting Grok cached input tokens (#5838)

Grok Build reports cachedReadTokens inside inputTokens (totalTokens ==
input + output on a real 0.2.106 turn, and that turn's costUsdTicks
matches xAI's rates only when the cached prefix is billed once). The
shared ACP parser persisted both counters raw, so the usage dashboard
charged the cached prefix at the full input rate *and* the cache-read
rate — ~4x the real spend on a cache-heavy turn.

Re-bucket cached reads out of input when totalTokens proves the overlap,
the same normalization codex.go already applies. Backends that report
mutually-exclusive buckets or omit totalTokens are untouched.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-07-23 17:55:33 +08:00
committed by GitHub
parent 98072e2e56
commit ecbdbda09e
3 changed files with 95 additions and 4 deletions

View File

@@ -610,7 +610,10 @@ func TestGrokPropagatesMCPAndUsage(t *testing.T) {
if !ok {
t.Fatalf("usage missing grok-4.5 key: %+v", result.Usage)
}
if usage.InputTokens != 120 || usage.OutputTokens != 30 || usage.CacheReadTokens != 20 {
// The fixture's totalTokens (150) equals input + output, so its 20 cached
// reads sit inside inputTokens and are billed once: input is stored as the
// uncached remainder 120 - 20 = 100.
if usage.InputTokens != 100 || usage.OutputTokens != 30 || usage.CacheReadTokens != 20 {
t.Fatalf("unexpected usage: %+v", usage)
}
}

View File

@@ -1649,7 +1649,7 @@ func parseACPTokenUsage(data json.RawMessage) TokenUsage {
if err := json.Unmarshal(data, &fields); err != nil {
return TokenUsage{}
}
return TokenUsage{
usage := TokenUsage{
InputTokens: acpUsageInt64(fields, "inputTokens", "input_tokens"),
OutputTokens: acpUsageInt64(fields, "outputTokens", "output_tokens"),
CacheReadTokens: acpUsageInt64(fields,
@@ -1666,6 +1666,37 @@ func parseACPTokenUsage(data json.RawMessage) TokenUsage {
"cache_creation_input_tokens",
),
}
return excludeACPCachedInput(usage, acpUsageInt64(fields, "totalTokens", "total_tokens"))
}
// excludeACPCachedInput re-buckets a usage record whose `inputTokens` already
// contains `cachedReadTokens`, so the persisted buckets stay mutually
// exclusive and dashboard cost math does not charge the cached prefix twice
// (same normalization codex.go applies via codexUncachedInputTokens).
//
// ACP does not specify whether cached reads are counted inside inputTokens.
// Grok Build counts them inside: a real `grok 0.2.106` turn reports
// inputTokens=12929, cachedReadTokens=10880, outputTokens=29,
// totalTokens=12958 — i.e. total == input + output, so the cached prefix is
// counted once, within input. The same payload's costUsdTicks=75360000
// ($0.007536) matches exactly (12929-10880) uncached input + 10880 cached
// read + 29 output at xAI's published grok-4.5 rates, confirming how xAI
// bills it. Kept raw, that turn is priced as if 12929 tokens were uncached —
// ~4x the real spend on a cache-heavy turn.
//
// `totalTokens` is the only self-describing signal available, so the
// re-bucketing only happens when it is present and equals input + output.
// Agents that report exclusive buckets (total == input + cached + output) or
// omit totalTokens keep their counters untouched.
func excludeACPCachedInput(usage TokenUsage, totalTokens int64) TokenUsage {
if totalTokens <= 0 || usage.CacheReadTokens <= 0 || usage.CacheReadTokens > usage.InputTokens {
return usage
}
if totalTokens != usage.InputTokens+usage.OutputTokens {
return usage
}
usage.InputTokens -= usage.CacheReadTokens
return usage
}
func acpUsageInt64(fields map[string]json.RawMessage, names ...string) int64 {

View File

@@ -1121,6 +1121,58 @@ func TestParseACPTokenUsageAliases(t *testing.T) {
}
}
// TestParseACPTokenUsageCachedInputBucketing pins when cached reads are moved
// out of inputTokens. Persisting overlapping buckets makes the dashboard
// charge the cached prefix at both the full input rate and the cache-read
// rate, so the re-bucketing must fire exactly when totalTokens proves the
// counters overlap — and never otherwise.
func TestParseACPTokenUsageCachedInputBucketing(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
want TokenUsage
}{
{
// Grok Build: totalTokens == input + output, so the cached prefix
// lives inside inputTokens.
name: "inclusive input is reduced by cached reads",
raw: `{"inputTokens":12929,"outputTokens":29,"totalTokens":12958,"cachedReadTokens":10880}`,
want: TokenUsage{InputTokens: 2049, OutputTokens: 29, CacheReadTokens: 10880},
},
{
// totalTokens == input + cached + output: the buckets are already
// mutually exclusive.
name: "exclusive buckets are left alone",
raw: `{"inputTokens":100,"outputTokens":20,"totalTokens":150,"cachedReadTokens":30}`,
want: TokenUsage{InputTokens: 100, OutputTokens: 20, CacheReadTokens: 30},
},
{
// No totalTokens: nothing in the payload describes the overlap, so
// the counters are persisted as reported.
name: "missing totalTokens keeps counters as reported",
raw: `{"inputTokens":100,"outputTokens":20,"cachedReadTokens":30}`,
want: TokenUsage{InputTokens: 100, OutputTokens: 20, CacheReadTokens: 30},
},
{
// Cached reads larger than input cannot be a subset of it.
name: "cached larger than input keeps counters as reported",
raw: `{"inputTokens":10,"outputTokens":20,"totalTokens":30,"cachedReadTokens":40}`,
want: TokenUsage{InputTokens: 10, OutputTokens: 20, CacheReadTokens: 40},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := parseACPTokenUsage(json.RawMessage(tt.raw)); got != tt.want {
t.Fatalf("got %+v, want %+v", got, tt.want)
}
})
}
}
func TestHermesClientHandleToolCallComplete(t *testing.T) {
t.Parallel()
@@ -1512,8 +1564,13 @@ func TestHermesClientExtractPromptResultMetaUsage(t *testing.T) {
if got.stopReason != "end_turn" {
t.Errorf("stopReason: got %q, want %q", got.stopReason, "end_turn")
}
if got.usage.InputTokens != 12929 {
t.Errorf("inputTokens: got %d, want 12929", got.usage.InputTokens)
// Grok counts the cached prefix inside inputTokens (totalTokens 12958 ==
// 12929 + 29), so the stored input bucket is the uncached remainder:
// 12929 - 10880 = 2049. Priced at xAI's grok-4.5 rates that is
// 2049*$2 + 10880*$0.30 + 29*$6 per 1M = $0.007536, which is exactly the
// costUsdTicks the same payload reports.
if got.usage.InputTokens != 2049 {
t.Errorf("inputTokens: got %d, want 2049", got.usage.InputTokens)
}
if got.usage.OutputTokens != 29 {
t.Errorf("outputTokens: got %d, want 29", got.usage.OutputTokens)