diff --git a/internal/ai/cost/coverage_test.go b/internal/ai/cost/coverage_test.go index 2e2cf728a..2ae23a162 100644 --- a/internal/ai/cost/coverage_test.go +++ b/internal/ai/cost/coverage_test.go @@ -49,17 +49,17 @@ func TestNormalizeModelForProvider(t *testing.T) { } func TestLookupPriceAndPatterns(t *testing.T) { - if _, ok := lookupPrice("", "gpt-4o"); ok { + if _, ok := lookupPrice("", "gpt-4o", 0); ok { t.Fatal("expected empty provider to be unknown") } - if _, ok := lookupPrice("openai", ""); ok { + if _, ok := lookupPrice("openai", "", 0); ok { t.Fatal("expected empty model to be unknown") } - if _, ok := lookupPrice("unknown", "model"); ok { + if _, ok := lookupPrice("unknown", "model", 0); ok { t.Fatal("expected unknown provider to be unknown") } - price, ok := lookupPrice("openai", "gpt-4o-mini") + price, ok := lookupPrice("openai", "gpt-4o-mini", 0) if !ok || price.InputUSDPerMTok == 0 { t.Fatalf("expected openai pricing match, got ok=%v price=%+v", ok, price) } @@ -78,6 +78,32 @@ func TestLookupPriceAndPatterns(t *testing.T) { } } +func TestLookupPrice_UsesTieredGeminiPricing(t *testing.T) { + under200k, ok := lookupPrice("gemini", "gemini-2.5-pro", 150_000) + if !ok { + t.Fatal("expected Gemini 2.5 Pro pricing to resolve") + } + if under200k.InputUSDPerMTok != 1.25 || under200k.OutputUSDPerMTok != 10.00 { + t.Fatalf("unexpected <=200k tier: %+v", under200k) + } + + over200k, ok := lookupPrice("gemini", "gemini-2.5-pro", 250_000) + if !ok { + t.Fatal("expected Gemini 2.5 Pro high-tier pricing to resolve") + } + if over200k.InputUSDPerMTok != 2.50 || over200k.OutputUSDPerMTok != 15.00 { + t.Fatalf("unexpected >200k tier: %+v", over200k) + } + + flash, ok := lookupPrice("gemini", "gemini-3-flash-preview", 50_000) + if !ok { + t.Fatal("expected Gemini 3 Flash Preview pricing to resolve") + } + if flash.InputUSDPerMTok != 0.50 || flash.OutputUSDPerMTok != 3.00 { + t.Fatalf("unexpected Gemini 3 Flash Preview pricing: %+v", flash) + } +} + func TestSetPersistence_LoadError(t *testing.T) { store := NewStore(30) mock := &savePersistence{loadErr: errors.New("load failed")} diff --git a/internal/ai/cost/pricing.go b/internal/ai/cost/pricing.go index e9f3db3db..7ce5f6a91 100644 --- a/internal/ai/cost/pricing.go +++ b/internal/ai/cost/pricing.go @@ -13,7 +13,7 @@ type TokenPrice struct { // EstimateUSD returns an estimated USD cost for the given provider/model and token counts. // If the model pricing is unknown, ok is false and usd is 0. func EstimateUSD(provider, model string, inputTokens, outputTokens int64) (usd float64, ok bool, price TokenPrice) { - price, ok = lookupPrice(provider, model) + price, ok = lookupPrice(provider, model, inputTokens) if !ok { return 0, false, TokenPrice{} } @@ -24,12 +24,18 @@ func EstimateUSD(provider, model string, inputTokens, outputTokens int64) (usd f } type modelPrice struct { - Pattern string + Pattern string + Tiers []priceTier +} + +type priceTier struct { + // MaxInputTokens is inclusive. Zero means "no upper bound". + MaxInputTokens int64 InputUSDPerMTok float64 OutputUSDPerMTok float64 } -const pricingAsOf = "2025-12" +const pricingAsOf = "2026-03-25" // PricingAsOf indicates the effective date of the pricing table used for estimation. func PricingAsOf() string { @@ -40,35 +46,61 @@ func PricingAsOf() string { // The goal is quick estimation and relative comparisons, not exact billing. var providerPrices = map[string][]modelPrice{ "openai": { - {Pattern: "gpt-4o*", InputUSDPerMTok: 5.00, OutputUSDPerMTok: 15.00}, - {Pattern: "gpt-4o-mini*", InputUSDPerMTok: 0.15, OutputUSDPerMTok: 0.60}, + flatPrice("gpt-4o*", 5.00, 15.00), + flatPrice("gpt-4o-mini*", 0.15, 0.60), }, "anthropic": { - {Pattern: "claude-opus*", InputUSDPerMTok: 15.00, OutputUSDPerMTok: 75.00}, - {Pattern: "claude-sonnet*", InputUSDPerMTok: 3.00, OutputUSDPerMTok: 15.00}, - {Pattern: "claude-haiku*", InputUSDPerMTok: 0.25, OutputUSDPerMTok: 1.25}, + flatPrice("claude-opus*", 15.00, 75.00), + flatPrice("claude-sonnet*", 3.00, 15.00), + flatPrice("claude-haiku*", 0.25, 1.25), }, "deepseek": { // DeepSeek docs include an "input cache hit" discount; this uses cache-miss rates for conservative estimates. - {Pattern: "deepseek-*", InputUSDPerMTok: 0.28, OutputUSDPerMTok: 0.42}, + flatPrice("deepseek-*", 0.28, 0.42), }, "gemini": { - // Gemini pricing (as of December 2025) - // Gemini 3 models are in preview, pricing may change - {Pattern: "gemini-3-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00}, - {Pattern: "gemini-3-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30}, - {Pattern: "gemini-2.5-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00}, - {Pattern: "gemini-2.5-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30}, - {Pattern: "gemini-1.5-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00}, - {Pattern: "gemini-1.5-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30}, - {Pattern: "gemini-*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30}, // Default to flash pricing + // Google Gemini Developer API pricing sourced from + // https://ai.google.dev/gemini-api/docs/pricing on 2026-03-25. + tieredPrice("gemini-3.1-pro-preview*", priceTier{MaxInputTokens: 200_000, InputUSDPerMTok: 2.00, OutputUSDPerMTok: 12.00}, priceTier{InputUSDPerMTok: 4.00, OutputUSDPerMTok: 18.00}), + tieredPrice("gemini-3-pro-image-preview*", priceTier{MaxInputTokens: 200_000, InputUSDPerMTok: 2.00, OutputUSDPerMTok: 12.00}, priceTier{InputUSDPerMTok: 4.00, OutputUSDPerMTok: 18.00}), + flatPrice("gemini-3-flash-preview*", 0.50, 3.00), + flatPrice("gemini-3.1-flash-image-preview*", 0.50, 3.00), + flatPrice("gemini-3.1-flash-lite-preview*", 0.25, 1.50), + tieredPrice("gemini-2.5-pro*", priceTier{MaxInputTokens: 200_000, InputUSDPerMTok: 1.25, OutputUSDPerMTok: 10.00}, priceTier{InputUSDPerMTok: 2.50, OutputUSDPerMTok: 15.00}), + flatPrice("gemini-2.5-flash*", 0.30, 2.50), + flatPrice("gemini-2.5-flash-lite-preview*", 0.10, 0.40), + flatPrice("gemini-2.5-flash-lite*", 0.10, 0.40), + flatPrice("gemini-2.0-flash*", 0.10, 0.40), + flatPrice("gemini-2.0-flash-lite*", 0.075, 0.30), + flatPrice("gemini-1.5-pro*", 1.25, 5.00), + flatPrice("gemini-1.5-flash*", 0.075, 0.30), + flatPrice("gemini-*", 0.30, 2.50), // Default to current non-lite Flash pricing. }, "ollama": { - {Pattern: "*", InputUSDPerMTok: 0, OutputUSDPerMTok: 0}, + flatPrice("*", 0, 0), }, } -func lookupPrice(provider, model string) (TokenPrice, bool) { +func flatPrice(pattern string, inputUSDPerMTok, outputUSDPerMTok float64) modelPrice { + return modelPrice{ + Pattern: pattern, + Tiers: []priceTier{ + { + InputUSDPerMTok: inputUSDPerMTok, + OutputUSDPerMTok: outputUSDPerMTok, + }, + }, + } +} + +func tieredPrice(pattern string, tiers ...priceTier) modelPrice { + return modelPrice{ + Pattern: pattern, + Tiers: tiers, + } +} + +func lookupPrice(provider, model string, inputTokens int64) (TokenPrice, bool) { provider = strings.ToLower(strings.TrimSpace(provider)) model = strings.ToLower(strings.TrimSpace(model)) if provider == "" || model == "" { @@ -82,9 +114,13 @@ func lookupPrice(provider, model string) (TokenPrice, bool) { for _, p := range prices { if matchPattern(model, strings.ToLower(p.Pattern)) { + tier, ok := selectPriceTier(p.Tiers, inputTokens) + if !ok { + return TokenPrice{}, false + } return TokenPrice{ - InputUSDPerMTok: p.InputUSDPerMTok, - OutputUSDPerMTok: p.OutputUSDPerMTok, + InputUSDPerMTok: tier.InputUSDPerMTok, + OutputUSDPerMTok: tier.OutputUSDPerMTok, AsOf: pricingAsOf, }, true } @@ -92,6 +128,21 @@ func lookupPrice(provider, model string) (TokenPrice, bool) { return TokenPrice{}, false } +func selectPriceTier(tiers []priceTier, inputTokens int64) (priceTier, bool) { + if len(tiers) == 0 { + return priceTier{}, false + } + if inputTokens < 0 { + inputTokens = 0 + } + for _, tier := range tiers { + if tier.MaxInputTokens == 0 || inputTokens <= tier.MaxInputTokens { + return tier, true + } + } + return tiers[len(tiers)-1], true +} + func matchPattern(model, pattern string) bool { if pattern == "*" { return true