feat: centralize cost breakdown calculations in pipeline

Extract token-type and per-model cost calculations from cmd/costs.go
into a dedicated pipeline.AggregateCostBreakdown() function. This
eliminates duplicate cost calculation logic between CLI and TUI.

New types:
- TokenTypeCosts: aggregate costs by input/output/cache types
- ModelCostBreakdown: per-model cost components

Benefits:
- Single source of truth for cost calculations
- Uses LookupPricingAt() for historical accuracy
- Both CLI and TUI now share the same cost logic
- Easier to maintain and extend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-23 10:08:55 -05:00
parent baa88efe75
commit 9bb0fd6b73
2 changed files with 112 additions and 43 deletions

View File

@@ -3,9 +3,8 @@ package cmd
import ( import (
"fmt" "fmt"
"cburn/internal/cli" "github.com/theirongolddev/cburn/internal/cli"
"cburn/internal/config" "github.com/theirongolddev/cburn/internal/pipeline"
"cburn/internal/pipeline"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -32,7 +31,7 @@ func runCosts(_ *cobra.Command, _ []string) error {
filtered, since, until := applyFilters(result.Sessions) filtered, since, until := applyFilters(result.Sessions)
stats := pipeline.Aggregate(filtered, since, until) stats := pipeline.Aggregate(filtered, since, until)
models := pipeline.AggregateModels(filtered, since, until) tokenCosts, modelCosts := pipeline.AggregateCostBreakdown(filtered, since, until)
if stats.TotalSessions == 0 { if stats.TotalSessions == 0 {
fmt.Println("\n No sessions in the selected time range.") fmt.Println("\n No sessions in the selected time range.")
@@ -54,30 +53,14 @@ func runCosts(_ *cobra.Command, _ []string) error {
cost float64 cost float64
} }
// Calculate costs per token type from raw token counts using canonical pricing totalCost := tokenCosts.TotalCost
var inputCost, outputCost, cache5mCost, cache1hCost, cacheReadCost float64
for _, s := range pipeline.FilterByTime(filtered, since, until) {
for modelName, mu := range s.Models {
p, ok := config.LookupPricing(modelName)
if !ok {
continue
}
inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1_000_000
outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1_000_000
cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1_000_000
cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1_000_000
cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1_000_000
}
}
totalCost := inputCost + outputCost + cache5mCost + cache1hCost + cacheReadCost
costs := []tokenCost{ costs := []tokenCost{
{"Output", outputCost}, {"Output", tokenCosts.OutputCost},
{"Cache Write (1h)", cache1hCost}, {"Cache Write (1h)", tokenCosts.Cache1hCost},
{"Input", inputCost}, {"Input", tokenCosts.InputCost},
{"Cache Write (5m)", cache5mCost}, {"Cache Write (5m)", tokenCosts.Cache5mCost},
{"Cache Read", cacheReadCost}, {"Cache Read", tokenCosts.CacheReadCost},
} }
// Sort by cost descending (already in expected order, but ensure) // Sort by cost descending (already in expected order, but ensure)
@@ -116,29 +99,22 @@ func runCosts(_ *cobra.Command, _ []string) error {
} }
// Cost by model // Cost by model
modelRows := make([][]string, 0, len(models)+2) modelRows := make([][]string, 0, len(modelCosts)+2)
for _, ms := range models { for _, mc := range modelCosts {
p, _ := config.LookupPricing(ms.Model)
mInput := float64(ms.InputTokens) * p.InputPerMTok / 1_000_000
mOutput := float64(ms.OutputTokens) * p.OutputPerMTok / 1_000_000
mCache := float64(ms.CacheCreation5m)*p.CacheWrite5mPerMTok/1_000_000 +
float64(ms.CacheCreation1h)*p.CacheWrite1hPerMTok/1_000_000 +
float64(ms.CacheReadTokens)*p.CacheReadPerMTok/1_000_000
modelRows = append(modelRows, []string{ modelRows = append(modelRows, []string{
shortModel(ms.Model), shortModel(mc.Model),
cli.FormatCost(mInput), cli.FormatCost(mc.InputCost),
cli.FormatCost(mOutput), cli.FormatCost(mc.OutputCost),
cli.FormatCost(mCache), cli.FormatCost(mc.CacheCost),
cli.FormatCost(ms.EstimatedCost), cli.FormatCost(mc.TotalCost),
}) })
} }
modelRows = append(modelRows, []string{"---"}) modelRows = append(modelRows, []string{"---"})
modelRows = append(modelRows, []string{ modelRows = append(modelRows, []string{
"TOTAL", "TOTAL",
cli.FormatCost(inputCost), cli.FormatCost(tokenCosts.InputCost),
cli.FormatCost(outputCost), cli.FormatCost(tokenCosts.OutputCost),
cli.FormatCost(cache5mCost + cache1hCost + cacheReadCost), cli.FormatCost(tokenCosts.CacheCost),
cli.FormatCost(totalCost), cli.FormatCost(totalCost),
}) })

View File

@@ -0,0 +1,93 @@
package pipeline
import (
"sort"
"time"
"github.com/theirongolddev/cburn/internal/config"
"github.com/theirongolddev/cburn/internal/model"
)
// TokenTypeCosts holds aggregate costs split by token type.
type TokenTypeCosts struct {
InputCost float64
OutputCost float64
Cache5mCost float64
Cache1hCost float64
CacheReadCost float64
CacheCost float64
TotalCost float64
}
// ModelCostBreakdown holds cost components for one model.
type ModelCostBreakdown struct {
Model string
InputCost float64
OutputCost float64
Cache5mCost float64
Cache1hCost float64
CacheReadCost float64
CacheCost float64
TotalCost float64
}
// AggregateCostBreakdown computes token-type and model cost splits.
// Pricing is resolved at each session timestamp.
func AggregateCostBreakdown(
sessions []model.SessionStats,
since time.Time,
until time.Time,
) (TokenTypeCosts, []ModelCostBreakdown) {
filtered := FilterByTime(sessions, since, until)
var totals TokenTypeCosts
byModel := make(map[string]*ModelCostBreakdown)
for _, s := range filtered {
for modelName, usage := range s.Models {
pricing, ok := config.LookupPricingAt(modelName, s.StartTime)
if !ok {
continue
}
inputCost := float64(usage.InputTokens) * pricing.InputPerMTok / 1_000_000
outputCost := float64(usage.OutputTokens) * pricing.OutputPerMTok / 1_000_000
cache5mCost := float64(usage.CacheCreation5mTokens) * pricing.CacheWrite5mPerMTok / 1_000_000
cache1hCost := float64(usage.CacheCreation1hTokens) * pricing.CacheWrite1hPerMTok / 1_000_000
cacheReadCost := float64(usage.CacheReadTokens) * pricing.CacheReadPerMTok / 1_000_000
totals.InputCost += inputCost
totals.OutputCost += outputCost
totals.Cache5mCost += cache5mCost
totals.Cache1hCost += cache1hCost
totals.CacheReadCost += cacheReadCost
row, exists := byModel[modelName]
if !exists {
row = &ModelCostBreakdown{Model: modelName}
byModel[modelName] = row
}
row.InputCost += inputCost
row.OutputCost += outputCost
row.Cache5mCost += cache5mCost
row.Cache1hCost += cache1hCost
row.CacheReadCost += cacheReadCost
}
}
totals.CacheCost = totals.Cache5mCost + totals.Cache1hCost + totals.CacheReadCost
totals.TotalCost = totals.InputCost + totals.OutputCost + totals.CacheCost
modelRows := make([]ModelCostBreakdown, 0, len(byModel))
for _, row := range byModel {
row.CacheCost = row.Cache5mCost + row.Cache1hCost + row.CacheReadCost
row.TotalCost = row.InputCost + row.OutputCost + row.CacheCost
modelRows = append(modelRows, *row)
}
sort.Slice(modelRows, func(i, j int) bool {
return modelRows[i].TotalCost > modelRows[j].TotalCost
})
return totals, modelRows
}