diff --git a/internal/api/api.go b/internal/api/api.go index 283f37e..f571dbb 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) { diff --git a/internal/calculator/calculator.go b/internal/calculator/calculator.go index 5245213..e30a6ba 100644 --- a/internal/calculator/calculator.go +++ b/internal/calculator/calculator.go @@ -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 } diff --git a/internal/calculator/calculator_test.go b/internal/calculator/calculator_test.go new file mode 100644 index 0000000..1722892 --- /dev/null +++ b/internal/calculator/calculator_test.go @@ -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) + } + }) + } +} diff --git a/internal/calculator/rates.go b/internal/calculator/rates.go index 217d345..46be8e1 100644 --- a/internal/calculator/rates.go +++ b/internal/calculator/rates.go @@ -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 } diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 8de5c03..73a3df3 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -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) @@ -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, }) } } @@ -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 { diff --git a/internal/store/queries.go b/internal/store/queries.go index 626947d..3a7cee7 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -34,7 +34,7 @@ func (s *Store) GetSummary() (*Summary, error) { // Today err := s.db.QueryRow(` SELECT COALESCE(SUM(total_cost), 0), - COALESCE(SUM(total_input + total_output + total_cache_read + total_cache_write), 0) + COALESCE(SUM(total_input + total_output + total_cache_read + total_cache_write_5m + total_cache_write_1h), 0) FROM sessions WHERE last_activity >= ?`, todayStr).Scan(&summary.Today.Cost, &summary.Today.Tokens) if err != nil { return nil, err @@ -43,7 +43,7 @@ func (s *Store) GetSummary() (*Summary, error) { // This week err = s.db.QueryRow(` SELECT COALESCE(SUM(total_cost), 0), - COALESCE(SUM(total_input + total_output + total_cache_read + total_cache_write), 0) + COALESCE(SUM(total_input + total_output + total_cache_read + total_cache_write_5m + total_cache_write_1h), 0) FROM sessions WHERE last_activity >= ?`, weekAgo).Scan(&summary.Week.Cost, &summary.Week.Tokens) if err != nil { return nil, err @@ -52,7 +52,7 @@ func (s *Store) GetSummary() (*Summary, error) { // This month err = s.db.QueryRow(` SELECT COALESCE(SUM(total_cost), 0), - COALESCE(SUM(total_input + total_output + total_cache_read + total_cache_write), 0) + COALESCE(SUM(total_input + total_output + total_cache_read + total_cache_write_5m + total_cache_write_1h), 0) FROM sessions WHERE last_activity >= ?`, monthStart).Scan(&summary.Month.Cost, &summary.Month.Tokens) if err != nil { return nil, err @@ -104,7 +104,8 @@ func (s *Store) GetDailySummary(days int) ([]DailySpend, error) { func (s *Store) TopSessions(n int) ([]Session, error) { rows, err := s.db.Query(`SELECT id, project, slug, model, started_at, last_activity, - total_input, total_output, total_cache_read, total_cache_write, total_cost + total_input, total_output, total_cache_read, + total_cache_write_5m, total_cache_write_1h, total_cost FROM sessions ORDER BY total_cost DESC LIMIT ?`, n) if err != nil { return nil, err @@ -116,10 +117,12 @@ func (s *Store) TopSessions(n int) ([]Session, error) { var sess Session if err := rows.Scan(&sess.ID, &sess.Project, &sess.Slug, &sess.Model, &sess.StartedAt, &sess.LastActivity, - &sess.TotalInput, &sess.TotalOutput, &sess.TotalCacheRead, &sess.TotalCacheWrite, + &sess.TotalInput, &sess.TotalOutput, &sess.TotalCacheRead, + &sess.TotalCacheWrite5m, &sess.TotalCacheWrite1h, &sess.TotalCost); err != nil { return nil, err } + sess.TotalCacheWrite = sess.TotalCacheWrite5m + sess.TotalCacheWrite1h sessions = append(sessions, sess) } return sessions, nil @@ -127,7 +130,8 @@ func (s *Store) TopSessions(n int) ([]Session, error) { func (s *Store) RecentSessions(n int) ([]Session, error) { rows, err := s.db.Query(`SELECT id, project, slug, model, started_at, last_activity, - total_input, total_output, total_cache_read, total_cache_write, total_cost + total_input, total_output, total_cache_read, + total_cache_write_5m, total_cache_write_1h, total_cost FROM sessions ORDER BY last_activity DESC LIMIT ?`, n) if err != nil { return nil, err @@ -139,10 +143,12 @@ func (s *Store) RecentSessions(n int) ([]Session, error) { var sess Session if err := rows.Scan(&sess.ID, &sess.Project, &sess.Slug, &sess.Model, &sess.StartedAt, &sess.LastActivity, - &sess.TotalInput, &sess.TotalOutput, &sess.TotalCacheRead, &sess.TotalCacheWrite, + &sess.TotalInput, &sess.TotalOutput, &sess.TotalCacheRead, + &sess.TotalCacheWrite5m, &sess.TotalCacheWrite1h, &sess.TotalCost); err != nil { return nil, err } + sess.TotalCacheWrite = sess.TotalCacheWrite5m + sess.TotalCacheWrite1h sessions = append(sessions, sess) } return sessions, nil @@ -171,11 +177,11 @@ func (s *Store) GetProjects() ([]ProjectSummary, error) { SELECT project, COUNT(*) as session_count, SUM(total_cost) as total_cost, - SUM(total_input + total_output + total_cache_read + total_cache_write) as total_tokens, + SUM(total_input + total_output + total_cache_read + total_cache_write_5m + total_cache_write_1h) as total_tokens, SUM(total_input) as total_input, SUM(total_output) as total_output, SUM(total_cache_read) as total_cache_read, - SUM(total_cache_write) as total_cache_write, + SUM(total_cache_write_5m + total_cache_write_1h) as total_cache_write, MAX(last_activity) as last_activity FROM sessions GROUP BY project @@ -228,7 +234,7 @@ func (s *Store) GetTokenBreakdown() (input, output, cacheRead, cacheWrite int64, SELECT COALESCE(SUM(total_input), 0), COALESCE(SUM(total_output), 0), COALESCE(SUM(total_cache_read), 0), - COALESCE(SUM(total_cache_write), 0) + COALESCE(SUM(total_cache_write_5m + total_cache_write_1h), 0) FROM sessions`).Scan(&input, &output, &cacheRead, &cacheWrite) return } @@ -242,7 +248,8 @@ type CostByType struct { func (s *Store) GetCostBreakdown() (*CostByType, error) { rows, err := s.db.Query(` - SELECT model, total_input, total_output, total_cache_read, total_cache_write + SELECT model, total_input, total_output, total_cache_read, + total_cache_write_5m, total_cache_write_1h FROM sessions`) if err != nil { return nil, err @@ -252,15 +259,16 @@ func (s *Store) GetCostBreakdown() (*CostByType, error) { result := &CostByType{} for rows.Next() { var model string - var inp, out, cr, cw int64 - if err := rows.Scan(&model, &inp, &out, &cr, &cw); err != nil { + var inp, out, cr, cw5m, cw1h int64 + if err := rows.Scan(&model, &inp, &out, &cr, &cw5m, &cw1h); err != nil { return nil, err } cb := calculator.Calculate(model, calculator.TokenUsage{ - InputTokens: inp, - OutputTokens: out, - CacheReadTokens: cr, - CacheWriteTokens: cw, + InputTokens: inp, + OutputTokens: out, + CacheReadTokens: cr, + CacheWrite5mTokens: cw5m, + CacheWrite1hTokens: cw1h, }) result.InputCost += cb.InputCost result.OutputCost += cb.OutputCost @@ -285,7 +293,7 @@ func (s *Store) GetModelBreakdown() ([]ModelSummary, error) { SELECT model, COUNT(*) as session_count, SUM(total_cost) as total_cost, - SUM(total_input + total_output + total_cache_read + total_cache_write) as total_tokens + SUM(total_input + total_output + total_cache_read + total_cache_write_5m + total_cache_write_1h) as total_tokens FROM sessions WHERE model != '' GROUP BY model diff --git a/internal/store/sessions.go b/internal/store/sessions.go index bf6f2d3..a0c27e3 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -3,34 +3,37 @@ package store import "fmt" type Session struct { - ID string `json:"id"` - Project string `json:"project"` - Slug string `json:"slug"` - Model string `json:"model"` - StartedAt string `json:"started_at"` - LastActivity string `json:"last_activity"` - TotalInput int64 `json:"total_input"` - TotalOutput int64 `json:"total_output"` - TotalCacheRead int64 `json:"total_cache_read"` - TotalCacheWrite int64 `json:"total_cache_write"` - TotalCost float64 `json:"total_cost"` + ID string `json:"id"` + Project string `json:"project"` + Slug string `json:"slug"` + Model string `json:"model"` + StartedAt string `json:"started_at"` + LastActivity string `json:"last_activity"` + TotalInput int64 `json:"total_input"` + TotalOutput int64 `json:"total_output"` + TotalCacheRead int64 `json:"total_cache_read"` + TotalCacheWrite5m int64 `json:"total_cache_write_5m"` + TotalCacheWrite1h int64 `json:"total_cache_write_1h"` + TotalCacheWrite int64 `json:"total_cache_write"` // derived: 5m + 1h, kept for UI compat + TotalCost float64 `json:"total_cost"` } func (s *Session) TotalTokens() int64 { - return s.TotalInput + s.TotalOutput + s.TotalCacheRead + s.TotalCacheWrite + return s.TotalInput + s.TotalOutput + s.TotalCacheRead + s.TotalCacheWrite5m + s.TotalCacheWrite1h } type SessionDelta struct { - ID string - Project string - Slug string - Model string - Timestamp string - DeltaInput int64 - DeltaOutput int64 - DeltaCacheRead int64 - DeltaCacheWrite int64 - DeltaCost float64 + ID string + Project string + Slug string + Model string + Timestamp string + DeltaInput int64 + DeltaOutput int64 + DeltaCacheRead int64 + DeltaCacheWrite5m int64 + DeltaCacheWrite1h int64 + DeltaCost float64 } // UpsertSession adds token deltas to an existing session or creates a new one. @@ -38,73 +41,85 @@ type SessionDelta struct { func (s *Store) UpsertSession(d SessionDelta) error { _, err := s.db.Exec(` INSERT INTO sessions (id, project, slug, model, started_at, last_activity, - total_input, total_output, total_cache_read, total_cache_write, total_cost) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + total_input, total_output, total_cache_read, + total_cache_write_5m, total_cache_write_1h, total_cost) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET slug = CASE WHEN excluded.slug != '' THEN excluded.slug ELSE sessions.slug END, model = CASE WHEN excluded.model != '' THEN excluded.model ELSE sessions.model END, last_activity = CASE WHEN excluded.last_activity > sessions.last_activity THEN excluded.last_activity ELSE sessions.last_activity END, total_input = sessions.total_input + excluded.total_input, total_output = sessions.total_output + excluded.total_output, - total_cache_read = sessions.total_cache_read + excluded.total_cache_read, - total_cache_write = sessions.total_cache_write + excluded.total_cache_write, + total_cache_read = sessions.total_cache_read + excluded.total_cache_read, + total_cache_write_5m = sessions.total_cache_write_5m + excluded.total_cache_write_5m, + total_cache_write_1h = sessions.total_cache_write_1h + excluded.total_cache_write_1h, total_cost = sessions.total_cost + excluded.total_cost `, d.ID, d.Project, d.Slug, d.Model, d.Timestamp, d.Timestamp, - d.DeltaInput, d.DeltaOutput, d.DeltaCacheRead, d.DeltaCacheWrite, d.DeltaCost) + d.DeltaInput, d.DeltaOutput, d.DeltaCacheRead, + d.DeltaCacheWrite5m, d.DeltaCacheWrite1h, d.DeltaCost) return err } func (s *Store) GetSession(id string) (*Session, error) { row := s.db.QueryRow(`SELECT id, project, slug, model, started_at, last_activity, - total_input, total_output, total_cache_read, total_cache_write, total_cost + total_input, total_output, total_cache_read, + total_cache_write_5m, total_cache_write_1h, total_cost FROM sessions WHERE id = ?`, id) sess := &Session{} err := row.Scan(&sess.ID, &sess.Project, &sess.Slug, &sess.Model, &sess.StartedAt, &sess.LastActivity, - &sess.TotalInput, &sess.TotalOutput, &sess.TotalCacheRead, &sess.TotalCacheWrite, + &sess.TotalInput, &sess.TotalOutput, &sess.TotalCacheRead, + &sess.TotalCacheWrite5m, &sess.TotalCacheWrite1h, &sess.TotalCost) if err != nil { return nil, err } + sess.TotalCacheWrite = sess.TotalCacheWrite5m + sess.TotalCacheWrite1h return sess, nil } // --- Request-level tracking --- type RequestRecord struct { - RequestID string `json:"request_id"` - SessionID string `json:"session_id"` - Timestamp string `json:"timestamp"` - Model string `json:"model"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheWriteTokens int64 `json:"cache_write_tokens"` - Cost float64 `json:"cost"` + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + Timestamp string `json:"timestamp"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWrite5mTokens int64 `json:"cache_write_5m_tokens"` + CacheWrite1hTokens int64 `json:"cache_write_1h_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` // derived: 5m + 1h + Cost float64 `json:"cost"` } func (s *Store) UpsertRequest(r RequestRecord) error { _, err := s.db.Exec(` INSERT INTO requests (request_id, session_id, timestamp, model, - input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + input_tokens, output_tokens, cache_read_tokens, + cache_write_5m_tokens, cache_write_1h_tokens, cost) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(request_id) DO UPDATE SET timestamp = excluded.timestamp, model = excluded.model, input_tokens = excluded.input_tokens, output_tokens = excluded.output_tokens, cache_read_tokens = excluded.cache_read_tokens, - cache_write_tokens = excluded.cache_write_tokens, + cache_write_5m_tokens = excluded.cache_write_5m_tokens, + cache_write_1h_tokens = excluded.cache_write_1h_tokens, cost = excluded.cost `, r.RequestID, r.SessionID, r.Timestamp, r.Model, - r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheWriteTokens, r.Cost) + r.InputTokens, r.OutputTokens, r.CacheReadTokens, + r.CacheWrite5mTokens, r.CacheWrite1hTokens, r.Cost) return err } func (s *Store) GetSessionRequests(sessionID string) ([]RequestRecord, error) { rows, err := s.db.Query(` SELECT request_id, session_id, timestamp, model, - input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost + input_tokens, output_tokens, cache_read_tokens, + cache_write_5m_tokens, cache_write_1h_tokens, cost FROM requests WHERE session_id = ? ORDER BY timestamp ASC`, sessionID) if err != nil { @@ -116,22 +131,24 @@ func (s *Store) GetSessionRequests(sessionID string) ([]RequestRecord, error) { for rows.Next() { var r RequestRecord if err := rows.Scan(&r.RequestID, &r.SessionID, &r.Timestamp, &r.Model, - &r.InputTokens, &r.OutputTokens, &r.CacheReadTokens, &r.CacheWriteTokens, + &r.InputTokens, &r.OutputTokens, &r.CacheReadTokens, + &r.CacheWrite5mTokens, &r.CacheWrite1hTokens, &r.Cost); err != nil { return nil, err } + r.CacheWriteTokens = r.CacheWrite5mTokens + r.CacheWrite1hTokens recs = append(recs, r) } return recs, nil } var allowedSortColumns = map[string]string{ - "cost": "total_cost", - "date": "last_activity", - "started": "started_at", - "tokens": "(total_input + total_output + total_cache_read + total_cache_write)", - "model": "model", - "project": "project", + "cost": "total_cost", + "date": "last_activity", + "started": "started_at", + "tokens": "(total_input + total_output + total_cache_read + total_cache_write_5m + total_cache_write_1h)", + "model": "model", + "project": "project", } func (s *Store) ListSessions(limit, offset int, sortBy, sortDir string) ([]Session, int, error) { @@ -151,7 +168,8 @@ func (s *Store) ListSessions(limit, offset int, sortBy, sortDir string) ([]Sessi } query := fmt.Sprintf(`SELECT id, project, slug, model, started_at, last_activity, - total_input, total_output, total_cache_read, total_cache_write, total_cost + total_input, total_output, total_cache_read, + total_cache_write_5m, total_cache_write_1h, total_cost FROM sessions ORDER BY %s %s LIMIT ? OFFSET ?`, col, dir) rows, err := s.db.Query(query, limit, offset) @@ -165,10 +183,12 @@ func (s *Store) ListSessions(limit, offset int, sortBy, sortDir string) ([]Sessi var sess Session if err := rows.Scan(&sess.ID, &sess.Project, &sess.Slug, &sess.Model, &sess.StartedAt, &sess.LastActivity, - &sess.TotalInput, &sess.TotalOutput, &sess.TotalCacheRead, &sess.TotalCacheWrite, + &sess.TotalInput, &sess.TotalOutput, &sess.TotalCacheRead, + &sess.TotalCacheWrite5m, &sess.TotalCacheWrite1h, &sess.TotalCost); err != nil { return nil, 0, err } + sess.TotalCacheWrite = sess.TotalCacheWrite5m + sess.TotalCacheWrite1h sessions = append(sessions, sess) } return sessions, total, nil diff --git a/internal/store/store.go b/internal/store/store.go index 1eae392..e06ea5f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -46,8 +46,9 @@ func (s *Store) migrate() error { last_activity TEXT NOT NULL DEFAULT '', total_input INTEGER NOT NULL DEFAULT 0, total_output INTEGER NOT NULL DEFAULT 0, - total_cache_read INTEGER NOT NULL DEFAULT 0, - total_cache_write INTEGER NOT NULL DEFAULT 0, + total_cache_read INTEGER NOT NULL DEFAULT 0, + total_cache_write_5m INTEGER NOT NULL DEFAULT 0, + total_cache_write_1h INTEGER NOT NULL DEFAULT 0, total_cost REAL NOT NULL DEFAULT 0 ); @@ -62,10 +63,11 @@ func (s *Store) migrate() error { session_id TEXT NOT NULL, timestamp TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_write_tokens INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_5m_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_1h_tokens INTEGER NOT NULL DEFAULT 0, cost REAL NOT NULL DEFAULT 0, FOREIGN KEY (session_id) REFERENCES sessions(id) ); diff --git a/web/src/api.ts b/web/src/api.ts index a4dfa65..a09bc07 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { Summary, SessionsResponse, Session, DailySpend, Settings, ModelRate, ProjectSummary, ProjectMonthly, ModelSummary, HeatmapCell, RequestRecord } from './types' +import type { Summary, SessionsResponse, Session, DailySpend, Settings, RatesResponse, ProjectSummary, ProjectMonthly, ModelSummary, HeatmapCell, RequestRecord } from './types' const BASE = '/api/v1' @@ -55,8 +55,8 @@ export async function fetchProjectMonthly(): Promise { return get('/projects/monthly') } -export async function fetchRates(): Promise { - return get('/rates') +export async function fetchRates(): Promise { + return get('/rates') } export async function fetchModels(): Promise { diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 6d84d05..488b9a0 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -54,7 +54,9 @@ export interface RequestRecord { input_tokens: number output_tokens: number cache_read_tokens: number - cache_write_tokens: number + cache_write_5m_tokens: number + cache_write_1h_tokens: number + cache_write_tokens: number // derived: 5m + 1h cost: number } @@ -68,7 +70,9 @@ export interface Session { total_input: number total_output: number total_cache_read: number - total_cache_write: number + total_cache_write_5m: number + total_cache_write_1h: number + total_cache_write: number // derived: 5m + 1h total_cost: number } @@ -94,10 +98,18 @@ export interface Settings { export interface ModelRate { Family: string + Released: string InputPerMToken: number OutputPerMToken: number CacheReadPerMToken: number - CacheWritePerMToken: number + CacheWrite5mPerMToken: number + CacheWrite1hPerMToken: number +} + +export interface RatesResponse { + version: string + updated: string + rates: ModelRate[] } export interface ProjectSummary { diff --git a/web/src/views/RateCard.vue b/web/src/views/RateCard.vue index 8df10b6..01fb414 100644 --- a/web/src/views/RateCard.vue +++ b/web/src/views/RateCard.vue @@ -2,7 +2,9 @@
@@ -10,19 +12,23 @@ Model + Released Input Output Cache Read - Cache Write + Cache Write 5m + Cache Write 1h {{ rate.Family }} + {{ rate.Released || '—' }} ${{ rate.InputPerMToken.toFixed(2) }} ${{ rate.OutputPerMToken.toFixed(2) }} ${{ rate.CacheReadPerMToken.toFixed(2) }} - ${{ rate.CacheWritePerMToken.toFixed(2) }} + ${{ rate.CacheWrite5mPerMToken.toFixed(2) }} + ${{ rate.CacheWrite1hPerMToken.toFixed(2) }} @@ -40,9 +46,14 @@ import type { ModelRate } from '../types' import { fetchRates } from '../api' const rates = ref([]) +const version = ref('') +const updated = ref('') onMounted(async () => { - rates.value = await fetchRates() + const r = await fetchRates() + rates.value = r.rates + version.value = r.version + updated.value = r.updated }) @@ -100,6 +111,12 @@ td.right { text-align: right; } font-family: 'JetBrains Mono', monospace; color: var(--text-primary); } +.released { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--text-tertiary); + white-space: nowrap; +} .price { font-family: 'JetBrains Mono', monospace; font-size: 13px;