diff --git a/internal/config/pricing.go b/internal/config/pricing.go index 798d981..74205e3 100644 --- a/internal/config/pricing.go +++ b/internal/config/pricing.go @@ -1,6 +1,9 @@ package config -import "strings" +import ( + "strings" + "time" +) // ModelPricing holds per-million-token prices for a model. type ModelPricing struct { @@ -14,6 +17,11 @@ type ModelPricing struct { LongOutputPerMTok float64 } +type modelPricingVersion struct { + EffectiveFrom time.Time + Pricing ModelPricing +} + // DefaultPricing maps model base names to their pricing. var DefaultPricing = map[string]ModelPricing{ "claude-opus-4-6": { @@ -63,12 +71,34 @@ var DefaultPricing = map[string]ModelPricing{ }, } +// defaultPricingHistory stores effective-dated prices for each model. +// Entries must be sorted by EffectiveFrom ascending. +var defaultPricingHistory = makeDefaultPricingHistory(DefaultPricing) + +func makeDefaultPricingHistory(base map[string]ModelPricing) map[string][]modelPricingVersion { + history := make(map[string][]modelPricingVersion, len(base)) + for modelName, pricing := range base { + history[modelName] = []modelPricingVersion{ + {Pricing: pricing}, + } + } + return history +} + +func hasPricingModel(model string) bool { + if _, ok := defaultPricingHistory[model]; ok { + return true + } + _, ok := DefaultPricing[model] + return ok +} + // NormalizeModelName strips date suffixes from model identifiers. // e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5" func NormalizeModelName(raw string) string { // Models can have date suffixes like -20251101 (8 digits) // Strategy: try progressively shorter prefixes against the pricing table - if _, ok := DefaultPricing[raw]; ok { + if hasPricingModel(raw) { return raw } @@ -78,7 +108,7 @@ func NormalizeModelName(raw string) string { last := parts[len(parts)-1] if isAllDigits(last) && len(last) >= 8 { candidate := strings.Join(parts[:len(parts)-1], "-") - if _, ok := DefaultPricing[candidate]; ok { + if hasPricingModel(candidate) { return candidate } } @@ -99,14 +129,51 @@ func isAllDigits(s string) bool { // LookupPricing returns the pricing for a model, normalizing the name first. // Returns zero pricing and false if the model is unknown. func LookupPricing(model string) (ModelPricing, bool) { + return LookupPricingAt(model, time.Now()) +} + +// LookupPricingAt returns the pricing for a model at the given timestamp. +// If at is zero, the latest known pricing entry is used. +func LookupPricingAt(model string, at time.Time) (ModelPricing, bool) { normalized := NormalizeModelName(model) - p, ok := DefaultPricing[normalized] - return p, ok + versions, ok := defaultPricingHistory[normalized] + if !ok || len(versions) == 0 { + p, fallback := DefaultPricing[normalized] + return p, fallback + } + + if at.IsZero() { + return versions[len(versions)-1].Pricing, true + } + + at = at.UTC() + selected := versions[0].Pricing + for _, v := range versions { + if v.EffectiveFrom.IsZero() || !at.Before(v.EffectiveFrom.UTC()) { + selected = v.Pricing + continue + } + break + } + return selected, true } // CalculateCost computes the estimated cost in USD for a single API call. func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, cacheRead int64) float64 { - pricing, ok := LookupPricing(model) + return CalculateCostAt(model, time.Now(), inputTokens, outputTokens, cache5m, cache1h, cacheRead) +} + +// CalculateCostAt computes the estimated cost in USD for a single API call at a point in time. +func CalculateCostAt( + model string, + at time.Time, + inputTokens, + outputTokens, + cache5m, + cache1h, + cacheRead int64, +) float64 { + pricing, ok := LookupPricingAt(model, at) if !ok { return 0 } @@ -124,7 +191,12 @@ func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, ca // CalculateCacheSavings computes how much the cache reads saved vs full input pricing. func CalculateCacheSavings(model string, cacheReadTokens int64) float64 { - pricing, ok := LookupPricing(model) + return CalculateCacheSavingsAt(model, time.Now(), cacheReadTokens) +} + +// CalculateCacheSavingsAt computes how much cache reads saved at a point in time. +func CalculateCacheSavingsAt(model string, at time.Time, cacheReadTokens int64) float64 { + pricing, ok := LookupPricingAt(model, at) if !ok { return 0 } diff --git a/internal/config/pricing_test.go b/internal/config/pricing_test.go new file mode 100644 index 0000000..a3cd746 --- /dev/null +++ b/internal/config/pricing_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "testing" + "time" +) + +func mustDate(t *testing.T, s string) time.Time { + t.Helper() + d, err := time.Parse("2006-01-02", s) + if err != nil { + t.Fatalf("parse date %q: %v", s, err) + } + return d +} + +func TestLookupPricingAt_UsesEffectiveDate(t *testing.T) { + model := "test-model-windowed" + orig, had := defaultPricingHistory[model] + if had { + defer func() { defaultPricingHistory[model] = orig }() + } else { + defer delete(defaultPricingHistory, model) + } + + defaultPricingHistory[model] = []modelPricingVersion{ + { + EffectiveFrom: mustDate(t, "2025-01-01"), + Pricing: ModelPricing{InputPerMTok: 1.0}, + }, + { + EffectiveFrom: mustDate(t, "2025-07-01"), + Pricing: ModelPricing{InputPerMTok: 2.0}, + }, + } + + aprPrice, ok := LookupPricingAt(model, mustDate(t, "2025-04-15")) + if !ok { + t.Fatal("LookupPricingAt returned !ok for historical model") + } + if aprPrice.InputPerMTok != 1.0 { + t.Fatalf("April price InputPerMTok = %.2f, want 1.0", aprPrice.InputPerMTok) + } + + augPrice, ok := LookupPricingAt(model, mustDate(t, "2025-08-15")) + if !ok { + t.Fatal("LookupPricingAt returned !ok for historical model in later window") + } + if augPrice.InputPerMTok != 2.0 { + t.Fatalf("August price InputPerMTok = %.2f, want 2.0", augPrice.InputPerMTok) + } +} + +func TestLookupPricingAt_UsesLatestWhenTimeZero(t *testing.T) { + model := "test-model-latest" + orig, had := defaultPricingHistory[model] + if had { + defer func() { defaultPricingHistory[model] = orig }() + } else { + defer delete(defaultPricingHistory, model) + } + + defaultPricingHistory[model] = []modelPricingVersion{ + { + EffectiveFrom: mustDate(t, "2025-01-01"), + Pricing: ModelPricing{InputPerMTok: 1.0}, + }, + { + EffectiveFrom: mustDate(t, "2025-09-01"), + Pricing: ModelPricing{InputPerMTok: 3.0}, + }, + } + + price, ok := LookupPricingAt(model, time.Time{}) + if !ok { + t.Fatal("LookupPricingAt returned !ok for model with pricing history") + } + if price.InputPerMTok != 3.0 { + t.Fatalf("zero-time lookup InputPerMTok = %.2f, want 3.0", price.InputPerMTok) + } +}