Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/cli/copilot_events_jsonl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
76 changes: 40 additions & 36 deletions pkg/cli/token_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,28 +80,23 @@ 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"`
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"`
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"`
Expand Down Expand Up @@ -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,
}
}

Expand Down
52 changes: 37 additions & 15 deletions pkg/cli/token_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
},
},
}
Expand All @@ -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")
})
}

Expand Down Expand Up @@ -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)
Expand Down
Loading