diff --git a/pkg/cli/copilot_events_jsonl.go b/pkg/cli/copilot_events_jsonl.go index 2c97d11c4bd..04c9ef3c92c 100644 --- a/pkg/cli/copilot_events_jsonl.go +++ b/pkg/cli/copilot_events_jsonl.go @@ -97,6 +97,9 @@ type copilotRequestMetrics struct { } // copilotUsageMetrics holds token usage for a model. +// NOTE: JSON tags intentionally use camelCase to match the Copilot events.jsonl +// format written by the Copilot CLI. This differs from the snake_case convention +// used in TokenCoreMetrics for our own token-usage files. type copilotUsageMetrics struct { InputTokens int `json:"inputTokens"` OutputTokens int `json:"outputTokens"` diff --git a/pkg/cli/token_usage.go b/pkg/cli/token_usage.go index cb42867e17e..55679e4b26e 100644 --- a/pkg/cli/token_usage.go +++ b/pkg/cli/token_usage.go @@ -23,24 +23,31 @@ import ( var tokenUsageLog = logger.New("cli:token_usage") +// TokenCoreMetrics is the single source of truth for the token-usage quartet +// shared across per-request, per-model, and per-run representations. +// All JSON tags use snake_case to match the token-usage.jsonl file format. +type TokenCoreMetrics struct { + InputTokens int `json:"input_tokens" console:"header:Input,format:number"` + OutputTokens int `json:"output_tokens" console:"header:Output,format:number"` + CacheReadTokens int `json:"cache_read_tokens" console:"header:Cache Read,format:number"` + CacheWriteTokens int `json:"cache_write_tokens" console:"header:Cache Write,format:number"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + EffectiveTokens int `json:"effective_tokens,omitempty"` +} + // TokenUsageEntry represents a single line from token-usage.jsonl type TokenUsageEntry struct { - Schema string `json:"_schema,omitempty"` // Self-describing record type, e.g. "token-usage/v0.26.0" - Timestamp string `json:"timestamp"` - RequestID string `json:"request_id"` - Provider string `json:"provider"` - Model string `json:"model"` - Path string `json:"path"` - Status int `json:"status"` - Streaming bool `json:"streaming"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CacheReadTokens int `json:"cache_read_tokens"` - CacheWriteTokens int `json:"cache_write_tokens"` - ReasoningTokens int `json:"reasoning_tokens"` - EffectiveTokens int `json:"effective_tokens"` - DurationMs int `json:"duration_ms"` - ResponseBytes int `json:"response_bytes"` + Schema string `json:"_schema,omitempty"` // Self-describing record type, e.g. "token-usage/v0.26.0" + Timestamp string `json:"timestamp"` + RequestID string `json:"request_id"` + Provider string `json:"provider"` + Model string `json:"model"` + Path string `json:"path"` + Status int `json:"status"` + Streaming bool `json:"streaming"` + TokenCoreMetrics + DurationMs int `json:"duration_ms"` + ResponseBytes int `json:"response_bytes"` } // AmbientContextMetrics captures token footprint for the first LLM invocation. @@ -73,20 +80,16 @@ type TokenUsageSummary struct { // ModelTokenUsage contains per-model token usage statistics type ModelTokenUsage struct { - Provider string `json:"provider"` - InputTokens int `json:"input_tokens" console:"header:Input,format:number"` - OutputTokens int `json:"output_tokens" console:"header:Output,format:number"` - CacheReadTokens int `json:"cache_read_tokens" console:"header:Cache Read,format:number"` - CacheWriteTokens int `json:"cache_write_tokens" console:"header:Cache Write,format:number"` - ReasoningTokens int `json:"reasoning_tokens,omitempty"` - Requests int `json:"requests" console:"header:Requests"` - DurationMs int `json:"duration_ms"` - ResponseBytes int `json:"response_bytes"` - EffectiveTokens int `json:"effective_tokens,omitempty"` - AIC float64 `json:"aic,omitempty"` + Provider string `json:"provider"` + TokenCoreMetrics + Requests int `json:"requests" console:"header:Requests"` + DurationMs int `json:"duration_ms"` + ResponseBytes int `json:"response_bytes"` + AIC float64 `json:"aic,omitempty"` } -// ModelTokenUsageRow is a flattened version for console table rendering +// ModelTokenUsageRow is a table-rendering view of per-model token statistics. +// Keep this row schema limited to the token quartet to preserve output shape. type ModelTokenUsageRow struct { Model string `json:"model" console:"header:Model"` Provider string `json:"provider" console:"header:Provider"` @@ -94,7 +97,6 @@ type ModelTokenUsageRow struct { OutputTokens int `json:"output_tokens" console:"header:Output,format:number"` CacheReadTokens int `json:"cache_read_tokens" console:"header:Cache Read,format:number"` CacheWriteTokens int `json:"cache_write_tokens" console:"header:Cache Write,format:number"` - EffectiveTokens int `json:"effective_tokens,omitempty"` AIC float64 `json:"aic,omitempty"` Requests int `json:"requests" console:"header:Requests"` AvgDuration string `json:"avg_duration" console:"header:Avg Duration"` @@ -450,13 +452,15 @@ func parseAgentUsageFile(filePath string) (*TokenUsageSummary, error) { if hasTokenData { summary.TotalRequests = 1 summary.ByModel[model] = &ModelTokenUsage{ - Provider: provider, - InputTokens: entry.InputTokens, - OutputTokens: entry.OutputTokens, - CacheReadTokens: entry.CacheReadTokens, - CacheWriteTokens: entry.CacheWriteTokens, - ReasoningTokens: entry.ReasoningTokens, - Requests: 1, + Provider: provider, + TokenCoreMetrics: TokenCoreMetrics{ + InputTokens: entry.InputTokens, + OutputTokens: entry.OutputTokens, + CacheReadTokens: entry.CacheReadTokens, + CacheWriteTokens: entry.CacheWriteTokens, + ReasoningTokens: entry.ReasoningTokens, + }, + Requests: 1, } } diff --git a/pkg/cli/token_usage_test.go b/pkg/cli/token_usage_test.go index a7d8af2ff19..83e1e4156f2 100644 --- a/pkg/cli/token_usage_test.go +++ b/pkg/cli/token_usage_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/testutil" "github.com/github/gh-aw/pkg/timeutil" "github.com/stretchr/testify/assert" @@ -358,19 +359,23 @@ func TestTokenUsageSummaryMethods(t *testing.T) { summary := &TokenUsageSummary{ ByModel: map[string]*ModelTokenUsage{ "small-model": { - Provider: "provider-a", - InputTokens: 10, - Requests: 1, - DurationMs: 100, + Provider: "provider-a", + TokenCoreMetrics: TokenCoreMetrics{InputTokens: 10}, + Requests: 1, + DurationMs: 100, }, "large-model": { - Provider: "provider-b", - InputTokens: 100, - OutputTokens: 200, - CacheReadTokens: 5000, - CacheWriteTokens: 3000, - Requests: 5, - DurationMs: 5000, + Provider: "provider-b", + TokenCoreMetrics: TokenCoreMetrics{ + InputTokens: 100, + OutputTokens: 200, + CacheReadTokens: 5000, + CacheWriteTokens: 3000, + ReasoningTokens: 30, + EffectiveTokens: 8330, + }, + Requests: 5, + DurationMs: 5000, }, }, } @@ -380,6 +385,21 @@ func TestTokenUsageSummaryMethods(t *testing.T) { assert.Equal(t, "large-model", rows[0].Model, "first row should be model with most tokens") assert.Equal(t, "small-model", rows[1].Model, "second row should be model with fewer tokens") assert.Equal(t, "1.0s", rows[0].AvgDuration, "avg duration for large model") + assert.Equal(t, 30, summary.ByModel["large-model"].ReasoningTokens, "reasoning tokens should remain tracked in model core metrics") + assert.Equal(t, 8330, summary.ByModel["large-model"].EffectiveTokens, "effective tokens should remain tracked in model core metrics") + + encoded, err := json.Marshal(rows[0]) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "reasoning_tokens", "row JSON should preserve legacy shape") + assert.NotContains(t, string(encoded), "effective_tokens", "row JSON should preserve legacy shape") + + rendered := console.RenderStruct(rows) + assert.Contains(t, rendered, "Input", "row table should keep quartet columns") + assert.Contains(t, rendered, "Output", "row table should keep quartet columns") + assert.Contains(t, rendered, "Cache Read", "row table should keep quartet columns") + assert.Contains(t, rendered, "Cache Write", "row table should keep quartet columns") + assert.NotContains(t, rendered, "ReasoningTokens", "row table should preserve legacy columns") + assert.NotContains(t, rendered, "EffectiveTokens", "row table should preserve legacy columns") }) } @@ -742,10 +762,12 @@ func TestCacheEfficiency(t *testing.T) { func TestModelTokenUsageReasoningTokensJSONRoundTrip(t *testing.T) { original := ModelTokenUsage{ - Provider: "anthropic", - InputTokens: 10, - OutputTokens: 20, - ReasoningTokens: 30, + Provider: "anthropic", + TokenCoreMetrics: TokenCoreMetrics{ + InputTokens: 10, + OutputTokens: 20, + ReasoningTokens: 30, + }, } raw, err := json.Marshal(original)