feat: add time-based pricing lookup for historical accuracy

Introduces LookupPricingAt() to resolve model pricing at a specific
timestamp instead of always using current prices. This is important
for accurate cost calculations when analyzing historical sessions
where pricing may have changed.

Changes:
- Add modelPricingVersion struct with EffectiveFrom timestamp
- Add defaultPricingHistory map for versioned pricing entries
- Update LookupPricing to delegate to LookupPricingAt(model, time.Now())
- Add comprehensive tests for time-windowed pricing lookup

The infrastructure supports future pricing changes by adding entries
to defaultPricingHistory with their effective dates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-23 10:08:46 -05:00
parent a386d95959
commit baa88efe75
2 changed files with 160 additions and 7 deletions

View File

@@ -1,6 +1,9 @@
package config package config
import "strings" import (
"strings"
"time"
)
// ModelPricing holds per-million-token prices for a model. // ModelPricing holds per-million-token prices for a model.
type ModelPricing struct { type ModelPricing struct {
@@ -14,6 +17,11 @@ type ModelPricing struct {
LongOutputPerMTok float64 LongOutputPerMTok float64
} }
type modelPricingVersion struct {
EffectiveFrom time.Time
Pricing ModelPricing
}
// DefaultPricing maps model base names to their pricing. // DefaultPricing maps model base names to their pricing.
var DefaultPricing = map[string]ModelPricing{ var DefaultPricing = map[string]ModelPricing{
"claude-opus-4-6": { "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. // NormalizeModelName strips date suffixes from model identifiers.
// e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5" // e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5"
func NormalizeModelName(raw string) string { func NormalizeModelName(raw string) string {
// Models can have date suffixes like -20251101 (8 digits) // Models can have date suffixes like -20251101 (8 digits)
// Strategy: try progressively shorter prefixes against the pricing table // Strategy: try progressively shorter prefixes against the pricing table
if _, ok := DefaultPricing[raw]; ok { if hasPricingModel(raw) {
return raw return raw
} }
@@ -78,7 +108,7 @@ func NormalizeModelName(raw string) string {
last := parts[len(parts)-1] last := parts[len(parts)-1]
if isAllDigits(last) && len(last) >= 8 { if isAllDigits(last) && len(last) >= 8 {
candidate := strings.Join(parts[:len(parts)-1], "-") candidate := strings.Join(parts[:len(parts)-1], "-")
if _, ok := DefaultPricing[candidate]; ok { if hasPricingModel(candidate) {
return candidate return candidate
} }
} }
@@ -99,14 +129,51 @@ func isAllDigits(s string) bool {
// LookupPricing returns the pricing for a model, normalizing the name first. // LookupPricing returns the pricing for a model, normalizing the name first.
// Returns zero pricing and false if the model is unknown. // Returns zero pricing and false if the model is unknown.
func LookupPricing(model string) (ModelPricing, bool) { 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) normalized := NormalizeModelName(model)
p, ok := DefaultPricing[normalized] versions, ok := defaultPricingHistory[normalized]
return p, ok 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. // CalculateCost computes the estimated cost in USD for a single API call.
func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, cacheRead int64) float64 { 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 { if !ok {
return 0 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. // CalculateCacheSavings computes how much the cache reads saved vs full input pricing.
func CalculateCacheSavings(model string, cacheReadTokens int64) float64 { 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 { if !ok {
return 0 return 0
} }

View File

@@ -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)
}
}