From 383b5879e6a4cf47189f62ddce6cea38852ff890 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:44:46 +0000 Subject: [PATCH] feat: implement Effective Tokens specification with model multipliers JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pkg/cli/data/model_multipliers.json with token class weights and per-model multipliers embedded at compile time - Add pkg/cli/effective_tokens.go implementing the ET formula: base_weighted_tokens = (w_in×I) + (w_cache×C) + (w_out×O) + (w_reason×R) effective_tokens = m × base_weighted_tokens - Add EffectiveTokens field to ModelTokenUsage, TotalEffectiveTokens to TokenUsageSummary, EffectiveTokens to WorkflowRun, RunData, MetricsData and TotalEffectiveTokens to LogsSummary - Populate effective tokens after token-usage.jsonl parsing; propagate to audit MetricsData and logs RunData/LogsSummary display - Add tests for multiplier lookup, base weighted tokens, ET computation, and populateEffectiveTokens Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1c8b15c4-3885-4c12-90d3-668d9a472adf Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/audit_report.go | 21 ++- pkg/cli/data/model_multipliers.json | 44 ++++++ pkg/cli/effective_tokens.go | 223 +++++++++++++++++++++++++++ pkg/cli/effective_tokens_test.go | 225 ++++++++++++++++++++++++++++ pkg/cli/logs_models.go | 1 + pkg/cli/logs_orchestrator.go | 10 ++ pkg/cli/logs_report.go | 28 ++-- pkg/cli/token_usage.go | 7 + 8 files changed, 542 insertions(+), 17 deletions(-) create mode 100644 pkg/cli/data/model_multipliers.json create mode 100644 pkg/cli/effective_tokens.go create mode 100644 pkg/cli/effective_tokens_test.go diff --git a/pkg/cli/audit_report.go b/pkg/cli/audit_report.go index 6eb08c24a41..f4021988563 100644 --- a/pkg/cli/audit_report.go +++ b/pkg/cli/audit_report.go @@ -97,12 +97,13 @@ type OverviewData struct { // MetricsData contains execution metrics type MetricsData struct { - TokenUsage int `json:"token_usage,omitempty" console:"header:Token Usage,format:number,omitempty"` - EstimatedCost float64 `json:"estimated_cost,omitempty" console:"header:Estimated Cost,format:cost,omitempty"` - ActionMinutes float64 `json:"action_minutes,omitempty" console:"header:Action Minutes,omitempty"` - Turns int `json:"turns,omitempty" console:"header:Turns,omitempty"` - ErrorCount int `json:"error_count" console:"header:Errors"` - WarningCount int `json:"warning_count" console:"header:Warnings"` + TokenUsage int `json:"token_usage,omitempty" console:"header:Token Usage,format:number,omitempty"` + EffectiveTokens int `json:"effective_tokens,omitempty" console:"header:Effective Tokens,format:number,omitempty"` + EstimatedCost float64 `json:"estimated_cost,omitempty" console:"header:Estimated Cost,format:cost,omitempty"` + ActionMinutes float64 `json:"action_minutes,omitempty" console:"header:Action Minutes,omitempty"` + Turns int `json:"turns,omitempty" console:"header:Turns,omitempty"` + ErrorCount int `json:"error_count" console:"header:Errors"` + WarningCount int `json:"warning_count" console:"header:Warnings"` } // JobData contains information about individual jobs @@ -276,6 +277,14 @@ func buildAuditData(processedRun ProcessedRun, metrics LogMetrics, mcpToolUsage WarningCount: run.WarningCount, } + // Populate effective tokens from the firewall proxy summary when available, + // otherwise fall back to the effective tokens stored on the run itself. + if processedRun.TokenUsage != nil && processedRun.TokenUsage.TotalEffectiveTokens > 0 { + metricsData.EffectiveTokens = processedRun.TokenUsage.TotalEffectiveTokens + } else if run.EffectiveTokens > 0 { + metricsData.EffectiveTokens = run.EffectiveTokens + } + // Populate ActionMinutes from run duration so it is always visible even // when token/turn metrics are zero (e.g. Codex runs that exit early). // Use math.Ceil to match the billable-minute rounding used elsewhere. diff --git a/pkg/cli/data/model_multipliers.json b/pkg/cli/data/model_multipliers.json new file mode 100644 index 00000000000..e011745cf25 --- /dev/null +++ b/pkg/cli/data/model_multipliers.json @@ -0,0 +1,44 @@ +{ + "version": "1", + "description": "Effective Tokens (ET) computation data per the gh-aw Effective Tokens Specification v0.2.0. Token class weights are applied first to normalize across token classes, then the per-model multiplier scales the result relative to the reference model.", + "reference_model": "claude-sonnet-4.5", + "token_class_weights": { + "input": 1.0, + "cached_input": 0.1, + "output": 4.0, + "reasoning": 4.0, + "cache_write": 1.0 + }, + "multipliers": { + "claude-haiku-4.5": 0.1, + "claude-3-5-haiku": 0.1, + "claude-3-haiku": 0.1, + "claude-sonnet-4.5": 1.0, + "claude-sonnet-4.6": 1.0, + "claude-3-5-sonnet": 1.0, + "claude-3-7-sonnet": 1.0, + "claude-3-sonnet": 1.0, + "claude-opus-4.5": 5.0, + "claude-opus-4.6": 5.0, + "claude-3-5-opus": 5.0, + "claude-3-opus": 5.0, + "gpt-4o": 1.0, + "gpt-4o-mini": 0.1, + "gpt-4.1": 1.0, + "gpt-4.1-mini": 0.1, + "gpt-4.1-nano": 0.05, + "gpt-4-turbo": 1.0, + "gpt-4": 1.0, + "o1": 3.0, + "o1-mini": 0.5, + "o1-pro": 10.0, + "o3": 3.0, + "o3-mini": 0.5, + "o4-mini": 0.5, + "gemini-2.5-pro": 1.0, + "gemini-2.5-flash": 0.2, + "gemini-2.0-flash": 0.1, + "gemini-1.5-pro": 1.0, + "gemini-1.5-flash": 0.1 + } +} diff --git a/pkg/cli/effective_tokens.go b/pkg/cli/effective_tokens.go new file mode 100644 index 00000000000..152adc397d6 --- /dev/null +++ b/pkg/cli/effective_tokens.go @@ -0,0 +1,223 @@ +package cli + +// This file provides command-line interface functionality for gh-aw. +// This file (effective_tokens.go) implements the Effective Tokens (ET) specification +// defined in docs/src/content/docs/reference/effective-tokens-specification.md. +// +// Effective Tokens normalize raw token counts across token classes and model pricing +// using the formula: +// +// base_weighted_tokens = (w_in × I) + (w_cache × C) + (w_out × O) + (w_reason × R) +// effective_tokens = m × base_weighted_tokens +// +// where: +// - I = input tokens (w_in = 1.0 default) +// - C = cached input tokens (w_cache = 0.1 default) +// - O = output tokens (w_out = 4.0 default) +// - R = reasoning tokens (w_reason = 4.0 default) +// - m = per-model multiplier relative to the reference model +// +// Token class weights and model multipliers are loaded from the embedded +// data/model_multipliers.json file and can be updated without recompilation. +// +// Key responsibilities: +// - Embedding model_multipliers.json at compile time +// - Applying token class weights before the model multiplier +// - Providing model multiplier lookup with prefix matching for model variants +// - Computing effective tokens from raw per-model token usage data +// - Populating effective token counts on TokenUsageSummary after parsing + +import ( + _ "embed" + "encoding/json" + "math" + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var effectiveTokensLog = logger.New("cli:effective_tokens") + +//go:embed data/model_multipliers.json +var modelMultipliersJSON []byte + +// tokenClassWeights holds the per-token-class weight values from the specification. +type tokenClassWeights struct { + Input float64 `json:"input"` + CachedInput float64 `json:"cached_input"` + Output float64 `json:"output"` + Reasoning float64 `json:"reasoning"` + CacheWrite float64 `json:"cache_write"` +} + +// modelMultipliersData is the top-level structure of model_multipliers.json. +type modelMultipliersData struct { + Version string `json:"version"` + Description string `json:"description"` + ReferenceModel string `json:"reference_model"` + TokenClassWeights tokenClassWeights `json:"token_class_weights"` + Multipliers map[string]float64 `json:"multipliers"` +} + +// loadedMultipliers is the parsed multiplier table, keyed by lowercase model name. +// Initialized once on first call to effectiveTokenMultiplier. +var loadedMultipliers map[string]float64 + +// loadedTokenWeights holds the token class weights from the JSON file. +// Initialized once on first call to initMultipliers. +var loadedTokenWeights tokenClassWeights + +// initMultipliers parses the embedded JSON and populates loadedMultipliers and +// loadedTokenWeights. Safe to call multiple times; only initializes once. +func initMultipliers() { + if loadedMultipliers != nil { + return + } + + var data modelMultipliersData + if err := json.Unmarshal(modelMultipliersJSON, &data); err != nil { + effectiveTokensLog.Printf("Failed to parse model_multipliers.json: %v", err) + loadedMultipliers = make(map[string]float64) + loadedTokenWeights = defaultTokenClassWeights() + return + } + + loadedMultipliers = make(map[string]float64, len(data.Multipliers)) + for model, mult := range data.Multipliers { + loadedMultipliers[strings.ToLower(model)] = mult + } + + // Fall back to default weights for any zero-valued field (zero means not set) + defaults := defaultTokenClassWeights() + loadedTokenWeights = data.TokenClassWeights + if loadedTokenWeights.Input == 0 { + loadedTokenWeights.Input = defaults.Input + } + if loadedTokenWeights.CachedInput == 0 { + loadedTokenWeights.CachedInput = defaults.CachedInput + } + if loadedTokenWeights.Output == 0 { + loadedTokenWeights.Output = defaults.Output + } + if loadedTokenWeights.Reasoning == 0 { + loadedTokenWeights.Reasoning = defaults.Reasoning + } + if loadedTokenWeights.CacheWrite == 0 { + loadedTokenWeights.CacheWrite = defaults.CacheWrite + } + + effectiveTokensLog.Printf("Loaded %d model multipliers (reference: %s, w_in=%.1f w_cache=%.1f w_out=%.1f)", + len(loadedMultipliers), data.ReferenceModel, + loadedTokenWeights.Input, loadedTokenWeights.CachedInput, loadedTokenWeights.Output) +} + +// defaultTokenClassWeights returns the specification-mandated default weights. +func defaultTokenClassWeights() tokenClassWeights { + return tokenClassWeights{ + Input: 1.0, + CachedInput: 0.1, + Output: 4.0, + Reasoning: 4.0, + CacheWrite: 1.0, + } +} + +// effectiveTokenMultiplier returns the per-model cost multiplier for the given model name. +// Lookup order: +// 1. Exact case-insensitive match +// 2. Longest prefix match (e.g. "claude-sonnet-4.6-preview" → "claude-sonnet-4.6") +// 3. Default: 1.0 (unknown model treated as reference baseline) +func effectiveTokenMultiplier(model string) float64 { + initMultipliers() + + key := strings.ToLower(strings.TrimSpace(model)) + if key == "" { + return 1.0 + } + + // Exact match + if mult, ok := loadedMultipliers[key]; ok { + return mult + } + + // Longest prefix match + best := "" + bestMult := 1.0 + for name, mult := range loadedMultipliers { + if strings.HasPrefix(key, name) && len(name) > len(best) { + best = name + bestMult = mult + } + } + + if best != "" { + effectiveTokensLog.Printf("Model %q matched via prefix %q (multiplier=%.2f)", model, best, bestMult) + return bestMult + } + + effectiveTokensLog.Printf("Unknown model %q, using default multiplier 1.0", model) + return 1.0 +} + +// computeBaseWeightedTokens computes the base weighted token count for a single invocation +// by applying per-token-class weights to the raw token counts. +// +// Formula (from the ET specification): +// +// base = (w_in × I) + (w_cache × C) + (w_out × O) + (w_reason × R) + (w_cache_write × W) +// +// where R (reasoning tokens) is currently not tracked separately and defaults to 0. +func computeBaseWeightedTokens(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) float64 { + initMultipliers() + w := loadedTokenWeights + return w.Input*float64(inputTokens) + + w.CachedInput*float64(cacheReadTokens) + + w.Output*float64(outputTokens) + + w.CacheWrite*float64(cacheWriteTokens) +} + +// computeModelEffectiveTokens returns the effective token count for a single model invocation. +// +// Formula (from the ET specification): +// +// effective_tokens = m × base_weighted_tokens +// +// The result is rounded to the nearest integer. +func computeModelEffectiveTokens(model string, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) int { + base := computeBaseWeightedTokens(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens) + if base == 0 { + return 0 + } + mult := effectiveTokenMultiplier(model) + return int(math.Round(base * mult)) +} + +// populateEffectiveTokens fills in the EffectiveTokens field on each ModelTokenUsage +// entry and computes the TotalEffectiveTokens aggregate on the summary. +// It is a no-op when summary is nil. +func populateEffectiveTokens(summary *TokenUsageSummary) { + if summary == nil { + return + } + + total := 0 + for model, usage := range summary.ByModel { + if usage == nil { + continue + } + eff := computeModelEffectiveTokens( + model, + usage.InputTokens, + usage.OutputTokens, + usage.CacheReadTokens, + usage.CacheWriteTokens, + ) + usage.EffectiveTokens = eff + total += eff + } + summary.TotalEffectiveTokens = total + + if effectiveTokensLog.Enabled() { + effectiveTokensLog.Printf("Effective tokens: total=%d models=%d", total, len(summary.ByModel)) + } +} diff --git a/pkg/cli/effective_tokens_test.go b/pkg/cli/effective_tokens_test.go new file mode 100644 index 00000000000..d00bdde0ccc --- /dev/null +++ b/pkg/cli/effective_tokens_test.go @@ -0,0 +1,225 @@ +//go:build !integration + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEffectiveTokenMultiplierExactMatch(t *testing.T) { + // Reset cached multipliers so tests start fresh + loadedMultipliers = nil + + tests := []struct { + model string + expected float64 + }{ + {"claude-sonnet-4.5", 1.0}, + {"claude-sonnet-4.6", 1.0}, + {"claude-haiku-4.5", 0.1}, + {"claude-opus-4.5", 5.0}, + {"gpt-4o", 1.0}, + {"gpt-4o-mini", 0.1}, + {"o1", 3.0}, + {"o3-mini", 0.5}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + got := effectiveTokenMultiplier(tt.model) + assert.InDelta(t, tt.expected, got, 1e-9, "multiplier for %q", tt.model) + }) + } +} + +func TestEffectiveTokenMultiplierCaseInsensitive(t *testing.T) { + loadedMultipliers = nil + + assert.InDelta(t, 1.0, effectiveTokenMultiplier("Claude-Sonnet-4.5"), 1e-9, "case-insensitive lookup should work") + assert.InDelta(t, 0.1, effectiveTokenMultiplier("CLAUDE-HAIKU-4.5"), 1e-9, "uppercase should match") +} + +func TestEffectiveTokenMultiplierPrefixMatch(t *testing.T) { + loadedMultipliers = nil + + // A model variant not in the table should match via longest prefix + got := effectiveTokenMultiplier("claude-sonnet-4.6-preview-20250101") + assert.InDelta(t, 1.0, got, 1e-9, "should match claude-sonnet-4.6 via prefix") + + got = effectiveTokenMultiplier("gpt-4o-mini-2024-07-18") + assert.InDelta(t, 0.1, got, 1e-9, "should match gpt-4o-mini via prefix") +} + +func TestEffectiveTokenMultiplierUnknownModel(t *testing.T) { + loadedMultipliers = nil + + // Completely unknown models should default to 1.0 + assert.InDelta(t, 1.0, effectiveTokenMultiplier("my-custom-model-v1"), 1e-9) + assert.InDelta(t, 1.0, effectiveTokenMultiplier(""), 1e-9) +} + +func TestComputeBaseWeightedTokens(t *testing.T) { + loadedMultipliers = nil + + tests := []struct { + name string + inputTokens int + outputTokens int + cacheReadTokens int + cacheWriteTokens int + expected float64 + }{ + { + name: "input and output only", + inputTokens: 1000, + outputTokens: 200, + // base = 1.0*1000 + 0.1*0 + 4.0*200 + 1.0*0 = 1000 + 800 = 1800 + expected: 1800, + }, + { + name: "with cache read", + inputTokens: 1000, + outputTokens: 200, + cacheReadTokens: 400, + // base = 1.0*1000 + 0.1*400 + 4.0*200 = 1000 + 40 + 800 = 1840 + expected: 1840, + }, + { + name: "with cache write", + inputTokens: 500, + outputTokens: 100, + cacheReadTokens: 200, + cacheWriteTokens: 100, + // base = 1.0*500 + 0.1*200 + 4.0*100 + 1.0*100 = 500 + 20 + 400 + 100 = 1020 + expected: 1020, + }, + { + name: "all zeros", + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeBaseWeightedTokens(tt.inputTokens, tt.outputTokens, tt.cacheReadTokens, tt.cacheWriteTokens) + assert.InDelta(t, tt.expected, got, 1e-9) + }) + } +} + +func TestComputeModelEffectiveTokens(t *testing.T) { + loadedMultipliers = nil + + tests := []struct { + name string + model string + inputTokens int + outputTokens int + cacheReadTokens int + cacheWriteTokens int + expected int + }{ + { + name: "sonnet at 1x", + model: "claude-sonnet-4.5", + inputTokens: 1000, + outputTokens: 200, + // base = 1.0*1000 + 4.0*200 = 1800; ET = 1.0 * 1800 = 1800 + expected: 1800, + }, + { + name: "haiku at 0.1x", + model: "claude-haiku-4.5", + inputTokens: 1000, + outputTokens: 200, + // base = 1800; ET = 0.1 * 1800 = 180 + expected: 180, + }, + { + name: "opus at 5x", + model: "claude-opus-4.5", + inputTokens: 1000, + outputTokens: 200, + // base = 1800; ET = 5.0 * 1800 = 9000 + expected: 9000, + }, + { + name: "includes cache tokens", + model: "claude-sonnet-4.5", + inputTokens: 500, + outputTokens: 100, + cacheReadTokens: 400, + cacheWriteTokens: 100, + // base = 1.0*500 + 0.1*400 + 4.0*100 + 1.0*100 = 500+40+400+100 = 1040 + // ET = 1.0 * 1040 = 1040 + expected: 1040, + }, + { + name: "zero tokens", + model: "claude-sonnet-4.5", + expected: 0, + }, + { + name: "unknown model defaults to 1x", + model: "unknown-model", + inputTokens: 500, + // base = 1.0*500 = 500; ET = 1.0 * 500 = 500 + expected: 500, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeModelEffectiveTokens(tt.model, tt.inputTokens, tt.outputTokens, tt.cacheReadTokens, tt.cacheWriteTokens) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestPopulateEffectiveTokens(t *testing.T) { + loadedMultipliers = nil + + summary := &TokenUsageSummary{ + ByModel: map[string]*ModelTokenUsage{ + "claude-sonnet-4.5": { + InputTokens: 1000, + OutputTokens: 200, + // base = 1.0*1000 + 4.0*200 = 1800; ET = 1.0 * 1800 = 1800 + }, + "claude-haiku-4.5": { + InputTokens: 2000, + OutputTokens: 400, + // base = 1.0*2000 + 4.0*400 = 3600; ET = 0.1 * 3600 = 360 + }, + }, + } + + populateEffectiveTokens(summary) + + sonnet := summary.ByModel["claude-sonnet-4.5"] + require.NotNil(t, sonnet) + assert.Equal(t, 1800, sonnet.EffectiveTokens, "sonnet effective tokens at 1x") + + haiku := summary.ByModel["claude-haiku-4.5"] + require.NotNil(t, haiku) + assert.Equal(t, 360, haiku.EffectiveTokens, "haiku effective tokens at 0.1x") + + assert.Equal(t, 2160, summary.TotalEffectiveTokens, "total = sonnet + haiku effective") +} + +func TestPopulateEffectiveTokensNilSummary(t *testing.T) { + // Should not panic on nil input + assert.NotPanics(t, func() { + populateEffectiveTokens(nil) + }) +} + +func TestModelMultipliersJSONEmbedded(t *testing.T) { + // Verify the embedded JSON parses without error + loadedMultipliers = nil + initMultipliers() + require.NotNil(t, loadedMultipliers, "multipliers should be loaded from embedded JSON") + assert.NotEmpty(t, loadedMultipliers, "should have at least one multiplier entry") +} diff --git a/pkg/cli/logs_models.go b/pkg/cli/logs_models.go index ab5f1197fb7..c39490ebc9d 100644 --- a/pkg/cli/logs_models.go +++ b/pkg/cli/logs_models.go @@ -60,6 +60,7 @@ type WorkflowRun struct { MissingDataCount int NoopCount int SafeItemsCount int + EffectiveTokens int // Cost-normalized token count computed from per-model multipliers LogsPath string } diff --git a/pkg/cli/logs_orchestrator.go b/pkg/cli/logs_orchestrator.go index 3885011dd1a..4363ce86ad8 100644 --- a/pkg/cli/logs_orchestrator.go +++ b/pkg/cli/logs_orchestrator.go @@ -336,6 +336,11 @@ func DownloadWorkflowLogs(ctx context.Context, workflowName string, count int, s run.WarningCount = 0 run.LogsPath = result.LogsPath + // Propagate effective tokens from cached firewall proxy summary when available + if result.TokenUsage != nil && result.TokenUsage.TotalEffectiveTokens > 0 { + run.EffectiveTokens = result.TokenUsage.TotalEffectiveTokens + } + // Add failed jobs to error count if failedJobCount, err := fetchJobStatuses(run.DatabaseID, verbose); err == nil { run.ErrorCount += failedJobCount @@ -781,6 +786,11 @@ func downloadRunArtifactsConcurrent(ctx context.Context, runs []WorkflowRun, out } result.TokenUsage = tokenUsage + // Propagate effective tokens from the firewall proxy summary when available + if tokenUsage != nil && tokenUsage.TotalEffectiveTokens > 0 { + result.Run.EffectiveTokens = tokenUsage.TotalEffectiveTokens + } + // Count safe output items created in GitHub (from manifest artifact) result.Run.SafeItemsCount = len(extractCreatedItemsFromManifest(runOutputDir)) diff --git a/pkg/cli/logs_report.go b/pkg/cli/logs_report.go index d59b9de1139..2565818d41f 100644 --- a/pkg/cli/logs_report.go +++ b/pkg/cli/logs_report.go @@ -58,6 +58,7 @@ type LogsSummary struct { TotalRuns int `json:"total_runs" console:"header:Total Runs"` TotalDuration string `json:"total_duration" console:"header:Total Duration"` TotalTokens int `json:"total_tokens" console:"header:Total Tokens,format:number"` + TotalEffectiveTokens int `json:"total_effective_tokens" console:"header:Total Effective Tokens,format:number"` TotalCost float64 `json:"total_cost" console:"header:Total Cost,format:cost"` TotalActionMinutes float64 `json:"total_action_minutes" console:"header:Total Action Minutes"` TotalTurns int `json:"total_turns" console:"header:Total Turns"` @@ -82,6 +83,7 @@ type RunData struct { Duration string `json:"duration,omitempty" console:"header:Duration,omitempty"` ActionMinutes float64 `json:"action_minutes,omitempty" console:"header:Action Minutes,omitempty"` TokenUsage int `json:"token_usage,omitempty" console:"header:Tokens,format:number,omitempty"` + EffectiveTokens int `json:"effective_tokens,omitempty" console:"header:Effective Tokens,format:number,omitempty"` EstimatedCost float64 `json:"estimated_cost,omitempty" console:"header:Cost ($),format:cost,omitempty"` Turns int `json:"turns,omitempty" console:"header:Turns,omitempty"` ErrorCount int `json:"error_count" console:"header:Errors"` @@ -162,6 +164,7 @@ func buildLogsData(processedRuns []ProcessedRun, outputDir string, continuation // Build summary var totalDuration time.Duration var totalTokens int + var totalEffectiveTokens int var totalCost float64 var totalActionMinutes float64 var totalTurns int @@ -181,6 +184,7 @@ func buildLogsData(processedRuns []ProcessedRun, outputDir string, continuation totalDuration += run.Duration } totalTokens += run.TokenUsage + totalEffectiveTokens += run.EffectiveTokens totalCost += run.EstimatedCost totalActionMinutes += run.ActionMinutes totalTurns += run.Turns @@ -215,6 +219,7 @@ func buildLogsData(processedRuns []ProcessedRun, outputDir string, continuation Status: run.Status, Conclusion: run.Conclusion, TokenUsage: run.TokenUsage, + EffectiveTokens: run.EffectiveTokens, EstimatedCost: run.EstimatedCost, ActionMinutes: run.ActionMinutes, Turns: run.Turns, @@ -255,17 +260,18 @@ func buildLogsData(processedRuns []ProcessedRun, outputDir string, continuation } summary := LogsSummary{ - TotalRuns: len(processedRuns), - TotalDuration: timeutil.FormatDuration(totalDuration), - TotalTokens: totalTokens, - TotalCost: totalCost, - TotalActionMinutes: totalActionMinutes, - TotalTurns: totalTurns, - TotalErrors: totalErrors, - TotalWarnings: totalWarnings, - TotalMissingTools: totalMissingTools, - TotalMissingData: totalMissingData, - TotalSafeItems: totalSafeItems, + TotalRuns: len(processedRuns), + TotalDuration: timeutil.FormatDuration(totalDuration), + TotalTokens: totalTokens, + TotalEffectiveTokens: totalEffectiveTokens, + TotalCost: totalCost, + TotalActionMinutes: totalActionMinutes, + TotalTurns: totalTurns, + TotalErrors: totalErrors, + TotalWarnings: totalWarnings, + TotalMissingTools: totalMissingTools, + TotalMissingData: totalMissingData, + TotalSafeItems: totalSafeItems, } episodes, edges := buildEpisodeData(runs, processedRuns) diff --git a/pkg/cli/token_usage.go b/pkg/cli/token_usage.go index 841a91f44ef..1fa37dbb6a4 100644 --- a/pkg/cli/token_usage.go +++ b/pkg/cli/token_usage.go @@ -42,6 +42,7 @@ type TokenUsageSummary struct { TotalDurationMs int `json:"total_duration_ms"` TotalResponseBytes int `json:"total_response_bytes"` CacheEfficiency float64 `json:"cache_efficiency"` + TotalEffectiveTokens int `json:"total_effective_tokens" console:"header:Effective Tokens,format:number"` ByModel map[string]*ModelTokenUsage `json:"by_model"` } @@ -55,6 +56,7 @@ type ModelTokenUsage struct { Requests int `json:"requests" console:"header:Requests"` DurationMs int `json:"duration_ms"` ResponseBytes int `json:"response_bytes"` + EffectiveTokens int `json:"effective_tokens" console:"header:Effective Tokens,format:number"` } // ModelTokenUsageRow is a flattened version for console table rendering @@ -65,6 +67,7 @@ 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" console:"header:Effective Tokens,format:number"` Requests int `json:"requests" console:"header:Requests"` AvgDuration string `json:"avg_duration" console:"header:Avg Duration"` } @@ -152,6 +155,9 @@ func parseTokenUsageFile(filePath string) (*TokenUsageSummary, error) { lineNum, summary.TotalInputTokens, summary.TotalOutputTokens, summary.TotalCacheReadTokens, summary.TotalCacheWriteTokens, summary.TotalRequests) + // Compute effective tokens using per-model multipliers + populateEffectiveTokens(summary) + return summary, nil } @@ -257,6 +263,7 @@ func (s *TokenUsageSummary) ModelRows() []ModelTokenUsageRow { OutputTokens: usage.OutputTokens, CacheReadTokens: usage.CacheReadTokens, CacheWriteTokens: usage.CacheWriteTokens, + EffectiveTokens: usage.EffectiveTokens, Requests: usage.Requests, AvgDuration: FormatDurationMs(avgDur), })