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:
62
cmd/costs.go
62
cmd/costs.go
@@ -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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
93
internal/pipeline/costs.go
Normal file
93
internal/pipeline/costs.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user