diff --git a/server/pkg/agent/grok_test.go b/server/pkg/agent/grok_test.go index 877c12dd72..f650cd4f07 100644 --- a/server/pkg/agent/grok_test.go +++ b/server/pkg/agent/grok_test.go @@ -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) } } diff --git a/server/pkg/agent/hermes.go b/server/pkg/agent/hermes.go index 70cd28ac5a..c3ce4a8551 100644 --- a/server/pkg/agent/hermes.go +++ b/server/pkg/agent/hermes.go @@ -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 { diff --git a/server/pkg/agent/hermes_test.go b/server/pkg/agent/hermes_test.go index 233b910a73..fee47b925b 100644 --- a/server/pkg/agent/hermes_test.go +++ b/server/pkg/agent/hermes_test.go @@ -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)