Skip to content
Open
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
6 changes: 5 additions & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,11 @@ func (a *API) handleProjectMonthly(w http.ResponseWriter, r *http.Request) {
}

func (a *API) handleRates(w http.ResponseWriter, r *http.Request) {
writeJSON(w, calculator.Rates)
writeJSON(w, map[string]any{
"version": calculator.RatesVersion,
"updated": calculator.RatesUpdated,
"rates": calculator.Rates,
})
}

func (a *API) handleModels(w http.ResponseWriter, r *http.Request) {
Expand Down
17 changes: 10 additions & 7 deletions internal/calculator/calculator.go
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
package calculator

type TokenUsage struct {
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheWriteTokens int64
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheWrite5mTokens int64
CacheWrite1hTokens int64
}

type CostBreakdown struct {
InputCost float64
OutputCost float64
CacheReadCost float64
CacheWriteCost float64
CacheWriteCost float64 // sum of 5m + 1h cache-write cost
TotalCost float64
}

func Calculate(model string, usage TokenUsage) CostBreakdown {
rates := GetRates(model)
cw5m := float64(usage.CacheWrite5mTokens) / 1_000_000 * rates.CacheWrite5mPerMToken
cw1h := float64(usage.CacheWrite1hTokens) / 1_000_000 * rates.CacheWrite1hPerMToken
cb := CostBreakdown{
InputCost: float64(usage.InputTokens) / 1_000_000 * rates.InputPerMToken,
OutputCost: float64(usage.OutputTokens) / 1_000_000 * rates.OutputPerMToken,
CacheReadCost: float64(usage.CacheReadTokens) / 1_000_000 * rates.CacheReadPerMToken,
CacheWriteCost: float64(usage.CacheWriteTokens) / 1_000_000 * rates.CacheWritePerMToken,
CacheWriteCost: cw5m + cw1h,
}
cb.TotalCost = cb.InputCost + cb.OutputCost + cb.CacheReadCost + cb.CacheWriteCost
return cb
}

func (u TokenUsage) Total() int64 {
return u.InputTokens + u.OutputTokens + u.CacheReadTokens + u.CacheWriteTokens
return u.InputTokens + u.OutputTokens + u.CacheReadTokens + u.CacheWrite5mTokens + u.CacheWrite1hTokens
}
43 changes: 43 additions & 0 deletions internal/calculator/calculator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package calculator

import "testing"

// Pricing assertions against https://platform.claude.com/docs/en/about-claude/pricing.
// Every case bills exactly 1M tokens of one kind so the expected cost equals the
// per-MTok rate and a failure pinpoints which rate moved.
func TestCalculatePricing(t *testing.T) {
cases := []struct {
label string
model string
usage TokenUsage
expect float64
}{
{"Opus 4.7 input", "claude-opus-4-7", TokenUsage{InputTokens: 1_000_000}, 5.00},
{"Opus 4.7 output", "claude-opus-4-7", TokenUsage{OutputTokens: 1_000_000}, 25.00},
{"Opus 4.6 input", "claude-opus-4-6", TokenUsage{InputTokens: 1_000_000}, 5.00},
{"Opus 4.5 input", "claude-opus-4-5-20251101", TokenUsage{InputTokens: 1_000_000}, 5.00},
{"Opus 4.1 input", "claude-opus-4-1-20250805", TokenUsage{InputTokens: 1_000_000}, 15.00},
{"Opus 4 input", "claude-opus-4-20250514", TokenUsage{InputTokens: 1_000_000}, 15.00},
{"Sonnet 4.6 input", "claude-sonnet-4-6", TokenUsage{InputTokens: 1_000_000}, 3.00},
{"Sonnet 4.6 output", "claude-sonnet-4-6", TokenUsage{OutputTokens: 1_000_000}, 15.00},
{"Sonnet 4.5 output", "claude-sonnet-4-5-20250929", TokenUsage{OutputTokens: 1_000_000}, 15.00},
{"Sonnet 4 input", "claude-sonnet-4-20250514", TokenUsage{InputTokens: 1_000_000}, 3.00},
{"Haiku 4.5 input", "claude-haiku-4-5-20251001", TokenUsage{InputTokens: 1_000_000}, 1.00},
{"Haiku 4.5 output", "claude-haiku-4-5-20251001", TokenUsage{OutputTokens: 1_000_000}, 5.00},
{"Haiku 3.5 input", "claude-haiku-3-5-20241022", TokenUsage{InputTokens: 1_000_000}, 0.80},
{"Opus 4.7 5m cache write", "claude-opus-4-7", TokenUsage{CacheWrite5mTokens: 1_000_000}, 6.25},
{"Opus 4.7 1h cache write", "claude-opus-4-7", TokenUsage{CacheWrite1hTokens: 1_000_000}, 10.00},
{"Sonnet 4 cache read", "claude-sonnet-4", TokenUsage{CacheReadTokens: 1_000_000}, 0.30},
// Unknown model falls back to Sonnet 4 pricing.
{"Unknown → fallback", "MLGO_0000501", TokenUsage{InputTokens: 1_000_000}, 3.00},
}

for _, c := range cases {
t.Run(c.label, func(t *testing.T) {
got := Calculate(c.model, c.usage).TotalCost
if got != c.expect {
t.Errorf("model=%q usage=%+v: want $%.4f, got $%.4f", c.model, c.usage, c.expect, got)
}
})
}
}
81 changes: 49 additions & 32 deletions internal/calculator/rates.go
Original file line number Diff line number Diff line change
@@ -1,46 +1,63 @@
package calculator

// RatesVersion / RatesUpdated identify the bundled rate card. Bump both whenever
// a rate changes or a model is added — the dashboard surfaces them so users can
// tell at a glance whether their build is on stale pricing.
const (
RatesVersion = "v1.3"
RatesUpdated = "2026-05-03"
)

type ModelRates struct {
Family string
InputPerMToken float64
OutputPerMToken float64
CacheReadPerMToken float64
CacheWritePerMToken float64
Family string
Released string // YYYY-MM-DD; empty if unknown
InputPerMToken float64
OutputPerMToken float64
CacheReadPerMToken float64
CacheWrite5mPerMToken float64
CacheWrite1hPerMToken float64
}

// Rates maps model family prefixes to their pricing.
// Models are matched by prefix: "claude-haiku-4-5-20251001" → "claude-haiku-4"
// Rates is matched top-to-bottom by prefix, so list more-specific families
// first (e.g. "claude-opus-4-7" before "claude-opus-4-1" before "claude-opus-4").
// Source: https://platform.claude.com/docs/en/about-claude/pricing
var Rates = []ModelRates{
{
Family: "claude-opus-4",
InputPerMToken: 15.00,
OutputPerMToken: 75.00,
CacheReadPerMToken: 1.50,
CacheWritePerMToken: 18.75,
},
{
Family: "claude-sonnet-4",
InputPerMToken: 3.00,
OutputPerMToken: 15.00,
CacheReadPerMToken: 0.30,
CacheWritePerMToken: 3.75,
},
{
Family: "claude-haiku-4",
InputPerMToken: 0.80,
OutputPerMToken: 4.00,
CacheReadPerMToken: 0.08,
CacheWritePerMToken: 1.00,
},
// Opus 4.5 / 4.6 / 4.7 — current tier (3x cheaper than the original Opus 4 / 4.1).
{Family: "claude-opus-4-7", Released: "2026-04-16", InputPerMToken: 5.00, OutputPerMToken: 25.00, CacheReadPerMToken: 0.50, CacheWrite5mPerMToken: 6.25, CacheWrite1hPerMToken: 10.00},
{Family: "claude-opus-4-6", Released: "2026-02-05", InputPerMToken: 5.00, OutputPerMToken: 25.00, CacheReadPerMToken: 0.50, CacheWrite5mPerMToken: 6.25, CacheWrite1hPerMToken: 10.00},
{Family: "claude-opus-4-5", Released: "2025-11-24", InputPerMToken: 5.00, OutputPerMToken: 25.00, CacheReadPerMToken: 0.50, CacheWrite5mPerMToken: 6.25, CacheWrite1hPerMToken: 10.00},
// Opus 4 / 4.1 — original tier.
{Family: "claude-opus-4-1", Released: "2025-08-05", InputPerMToken: 15.00, OutputPerMToken: 75.00, CacheReadPerMToken: 1.50, CacheWrite5mPerMToken: 18.75, CacheWrite1hPerMToken: 30.00},
{Family: "claude-opus-4", Released: "2025-05-22", InputPerMToken: 15.00, OutputPerMToken: 75.00, CacheReadPerMToken: 1.50, CacheWrite5mPerMToken: 18.75, CacheWrite1hPerMToken: 30.00},
// Sonnet 4 / 4.5 / 4.6 — same pricing across the family today, but listed
// explicitly so a future per-version repricing is a one-line change rather
// than a silent misattribution under the blanket prefix.
{Family: "claude-sonnet-4-6", Released: "2026-02-17", InputPerMToken: 3.00, OutputPerMToken: 15.00, CacheReadPerMToken: 0.30, CacheWrite5mPerMToken: 3.75, CacheWrite1hPerMToken: 6.00},
{Family: "claude-sonnet-4-5", Released: "2025-09-29", InputPerMToken: 3.00, OutputPerMToken: 15.00, CacheReadPerMToken: 0.30, CacheWrite5mPerMToken: 3.75, CacheWrite1hPerMToken: 6.00},
{Family: "claude-sonnet-4", Released: "2025-05-22", InputPerMToken: 3.00, OutputPerMToken: 15.00, CacheReadPerMToken: 0.30, CacheWrite5mPerMToken: 3.75, CacheWrite1hPerMToken: 6.00},
// Haiku 4.5.
{Family: "claude-haiku-4-5", Released: "2025-10-15", InputPerMToken: 1.00, OutputPerMToken: 5.00, CacheReadPerMToken: 0.10, CacheWrite5mPerMToken: 1.25, CacheWrite1hPerMToken: 2.00},
// Haiku 3.5 (still listed by Anthropic).
{Family: "claude-haiku-3-5", Released: "2024-10-22", InputPerMToken: 0.80, OutputPerMToken: 4.00, CacheReadPerMToken: 0.08, CacheWrite5mPerMToken: 1.00, CacheWrite1hPerMToken: 1.60},
// Deprecated — may appear in older logs.
{Family: "claude-sonnet-3-7", Released: "2025-02-24", InputPerMToken: 3.00, OutputPerMToken: 15.00, CacheReadPerMToken: 0.30, CacheWrite5mPerMToken: 3.75, CacheWrite1hPerMToken: 6.00},
{Family: "claude-opus-3", Released: "2024-03-04", InputPerMToken: 15.00, OutputPerMToken: 75.00, CacheReadPerMToken: 1.50, CacheWrite5mPerMToken: 18.75, CacheWrite1hPerMToken: 30.00},
{Family: "claude-haiku-3", Released: "2024-03-13", InputPerMToken: 0.25, OutputPerMToken: 1.25, CacheReadPerMToken: 0.03, CacheWrite5mPerMToken: 0.30, CacheWrite1hPerMToken: 0.50},
}

// sonnetFallback is used when no Family prefix matches; chosen as the most
// common Claude model so the error mode is "small over-estimate" rather than zero.
var sonnetFallback = ModelRates{
Family: "claude-sonnet-4 (fallback)", InputPerMToken: 3.00, OutputPerMToken: 15.00,
CacheReadPerMToken: 0.30, CacheWrite5mPerMToken: 3.75, CacheWrite1hPerMToken: 6.00,
}

func GetRates(model string) *ModelRates {
for i := range Rates {
if len(model) >= len(Rates[i].Family) && model[:len(Rates[i].Family)] == Rates[i].Family {
f := Rates[i].Family
if len(model) >= len(f) && model[:len(f)] == f {
return &Rates[i]
}
}
// Fallback: try matching shorter prefixes for unknown models
// Default to sonnet rates as the most common
return &Rates[1]
return &sonnetFallback
}
88 changes: 52 additions & 36 deletions internal/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,15 @@ func (p *Parser) ParseFile(path string) ([]string, error) {

// Aggregate token usage per session
type sessionAgg struct {
model string
slug string
sessionID string
timestamp string
input int64
output int64
cacheRead int64
cacheWrite int64
model string
slug string
sessionID string
timestamp string
input int64
output int64
cacheRead int64
cacheWrite5m int64
cacheWrite1h int64
}
sessions := make(map[string]*sessionAgg)

Expand Down Expand Up @@ -161,30 +162,43 @@ func (p *Parser) ParseFile(path string) ([]string, error) {
}

u := event.Message.Usage
// Split cache-creation tokens by TTL when the breakdown is present;
// fall back to all-5m when the breakdown is absent (older log lines).
var cw5m, cw1h int64
if u.CacheCreation != nil {
cw5m = u.CacheCreation.Ephemeral5m
cw1h = u.CacheCreation.Ephemeral1h
} else {
cw5m = u.CacheCreationInputTokens
}

agg.input += u.InputTokens
agg.output += u.OutputTokens
agg.cacheRead += u.CacheReadInputTokens
agg.cacheWrite += u.CacheCreationInputTokens
agg.cacheWrite5m += cw5m
agg.cacheWrite1h += cw1h

// Store per-request record if we have a requestID
if requestID != "" {
usage := calculator.TokenUsage{
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadInputTokens,
CacheWriteTokens: u.CacheCreationInputTokens,
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadInputTokens,
CacheWrite5mTokens: cw5m,
CacheWrite1hTokens: cw1h,
}
cost := calculator.Calculate(event.Message.Model, usage)
requestRecords = append(requestRecords, store.RequestRecord{
RequestID: requestID,
SessionID: sid,
Timestamp: event.Timestamp,
Model: event.Message.Model,
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadInputTokens,
CacheWriteTokens: u.CacheCreationInputTokens,
Cost: cost.TotalCost,
RequestID: requestID,
SessionID: sid,
Timestamp: event.Timestamp,
Model: event.Message.Model,
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadInputTokens,
CacheWrite5mTokens: cw5m,
CacheWrite1hTokens: cw1h,
Cost: cost.TotalCost,
})
}
}
Expand All @@ -200,25 +214,27 @@ func (p *Parser) ParseFile(path string) ([]string, error) {
var affectedIDs []string
for sid, agg := range sessions {
usage := calculator.TokenUsage{
InputTokens: agg.input,
OutputTokens: agg.output,
CacheReadTokens: agg.cacheRead,
CacheWriteTokens: agg.cacheWrite,
InputTokens: agg.input,
OutputTokens: agg.output,
CacheReadTokens: agg.cacheRead,
CacheWrite5mTokens: agg.cacheWrite5m,
CacheWrite1hTokens: agg.cacheWrite1h,
}
cost := calculator.Calculate(agg.model, usage)

project := info.Project
delta := store.SessionDelta{
ID: sid,
Project: project,
Slug: agg.slug,
Model: agg.model,
Timestamp: agg.timestamp,
DeltaInput: agg.input,
DeltaOutput: agg.output,
DeltaCacheRead: agg.cacheRead,
DeltaCacheWrite: agg.cacheWrite,
DeltaCost: cost.TotalCost,
ID: sid,
Project: project,
Slug: agg.slug,
Model: agg.model,
Timestamp: agg.timestamp,
DeltaInput: agg.input,
DeltaOutput: agg.output,
DeltaCacheRead: agg.cacheRead,
DeltaCacheWrite5m: agg.cacheWrite5m,
DeltaCacheWrite1h: agg.cacheWrite1h,
DeltaCost: cost.TotalCost,
}

if err := p.store.UpsertSession(delta); err != nil {
Expand Down
Loading