Address golangci-lint findings and improve error handling throughout:
Package doc comments:
- Add canonical "// Package X ..." comments to source, model, config,
pipeline, cli, store, and main packages for godoc compliance.
Security & correctness:
- Fix directory permissions 0o755 -> 0o750 in store/cache.go Open()
(gosec G301: restrict group write on cache directory)
- Fix config.Save() to check encoder error before closing file, preventing
silent data loss on encode failure
- Add //nolint:gosec annotations with justifications on intentional
patterns (constructed file paths, manual bounds checking, config fields)
- Add //nolint:nilerr on intentional error-swallowing in scanner WalkDir
- Add //nolint:revive on stuttering type names (ModelStats, ModelUsage)
that would break too many call sites to rename
Performance (perfsprint):
- Replace fmt.Sprintf("%d", n) with strconv.FormatInt(n, 10) in format.go
FormatTokens() and FormatNumber() hot paths
- Clean up redundant fmt.Sprintf patterns in FormatCost and FormatDelta
Code cleanup:
- Convert if-else chain to switch in parser.go skipJSONString() for clarity
- Remove unused indexedResult struct from pipeline/loader.go
- Add deferred cache.Close() in pipeline/bench_test.go to prevent leaks
- Add deferred cache.Close() in cmd/root.go data loading path
- Fix doc comment alignment in scanner.go decodeProjectName
- Remove trailing blank line in cmd/costs.go
- Fix duplicate "/day" suffix in cmd/summary.go cost-per-day formatting
- Rename shadowed variable 'max' -> 'maxVal' in cli/render.go Sparkline
164 lines
4.5 KiB
Go
164 lines
4.5 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"cburn/internal/cli"
|
|
"cburn/internal/config"
|
|
"cburn/internal/pipeline"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var costsCmd = &cobra.Command{
|
|
Use: "costs",
|
|
Short: "Cost breakdown by token type and model",
|
|
RunE: runCosts,
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(costsCmd)
|
|
}
|
|
|
|
func runCosts(_ *cobra.Command, _ []string) error {
|
|
result, err := loadData()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(result.Sessions) == 0 {
|
|
fmt.Println("\n No sessions found.")
|
|
return nil
|
|
}
|
|
|
|
filtered, since, until := applyFilters(result.Sessions)
|
|
stats := pipeline.Aggregate(filtered, since, until)
|
|
models := pipeline.AggregateModels(filtered, since, until)
|
|
|
|
if stats.TotalSessions == 0 {
|
|
fmt.Println("\n No sessions in the selected time range.")
|
|
return nil
|
|
}
|
|
|
|
// Previous period for comparison
|
|
prevDuration := until.Sub(since)
|
|
prevSince := since.Add(-prevDuration)
|
|
prevStats := pipeline.Aggregate(filtered, prevSince, since)
|
|
|
|
fmt.Println()
|
|
fmt.Println(cli.RenderTitle(fmt.Sprintf("COST BREAKDOWN Last %dd", flagDays)))
|
|
fmt.Println()
|
|
|
|
// Cost by token type
|
|
type tokenCost struct {
|
|
name string
|
|
cost float64
|
|
}
|
|
|
|
// Calculate costs per token type from raw token counts using canonical pricing
|
|
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{
|
|
{"Output", outputCost},
|
|
{"Cache Write (1h)", cache1hCost},
|
|
{"Input", inputCost},
|
|
{"Cache Write (5m)", cache5mCost},
|
|
{"Cache Read", cacheReadCost},
|
|
}
|
|
|
|
// Sort by cost descending (already in expected order, but ensure)
|
|
typeRows := make([][]string, 0, len(costs)+2)
|
|
for _, tc := range costs {
|
|
pct := ""
|
|
if totalCost > 0 {
|
|
pct = fmt.Sprintf("%.1f%%", tc.cost/totalCost*100)
|
|
}
|
|
typeRows = append(typeRows, []string{tc.name, cli.FormatCost(tc.cost), pct})
|
|
}
|
|
typeRows = append(typeRows, []string{"---"})
|
|
typeRows = append(typeRows, []string{"TOTAL", cli.FormatCost(totalCost), ""})
|
|
|
|
fmt.Print(cli.RenderTable(cli.Table{
|
|
Title: "By Token Type",
|
|
Headers: []string{"Type", "Cost", "Share"},
|
|
Rows: typeRows,
|
|
}))
|
|
|
|
// Period comparison
|
|
if prevStats.EstimatedCost > 0 {
|
|
fmt.Printf(" Period Comparison\n")
|
|
maxCost := stats.EstimatedCost
|
|
if prevStats.EstimatedCost > maxCost {
|
|
maxCost = prevStats.EstimatedCost
|
|
}
|
|
fmt.Printf(" This %dd %s %s\n",
|
|
flagDays,
|
|
cli.RenderHorizontalBar("", stats.EstimatedCost, maxCost, 30),
|
|
cli.FormatCost(stats.EstimatedCost))
|
|
fmt.Printf(" Prev %dd %s %s\n\n",
|
|
flagDays,
|
|
cli.RenderHorizontalBar("", prevStats.EstimatedCost, maxCost, 30),
|
|
cli.FormatCost(prevStats.EstimatedCost))
|
|
}
|
|
|
|
// Cost by model
|
|
modelRows := make([][]string, 0, len(models)+2)
|
|
for _, ms := range models {
|
|
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{
|
|
shortModel(ms.Model),
|
|
cli.FormatCost(mInput),
|
|
cli.FormatCost(mOutput),
|
|
cli.FormatCost(mCache),
|
|
cli.FormatCost(ms.EstimatedCost),
|
|
})
|
|
}
|
|
modelRows = append(modelRows, []string{"---"})
|
|
modelRows = append(modelRows, []string{
|
|
"TOTAL",
|
|
cli.FormatCost(inputCost),
|
|
cli.FormatCost(outputCost),
|
|
cli.FormatCost(cache5mCost + cache1hCost + cacheReadCost),
|
|
cli.FormatCost(totalCost),
|
|
})
|
|
|
|
fmt.Print(cli.RenderTable(cli.Table{
|
|
Title: "By Model",
|
|
Headers: []string{"Model", "Input", "Output", "Cache", "Total"},
|
|
Rows: modelRows,
|
|
}))
|
|
|
|
fmt.Printf(" Cache Savings: %s saved this period\n\n",
|
|
cli.FormatCost(stats.CacheSavings))
|
|
|
|
return nil
|
|
}
|
|
|
|
func shortModel(name string) string {
|
|
// "claude-opus-4-6" -> "opus-4-6"
|
|
if len(name) > 7 && name[:7] == "claude-" {
|
|
return name[7:]
|
|
}
|
|
return name
|
|
}
|