Fix Gemini cost estimation tiers (#1360)

This commit is contained in:
rcourtman
2026-03-25 09:55:17 +00:00
parent 5ea27a9e87
commit f9bf42498f
2 changed files with 103 additions and 26 deletions
+30 -4
View File
@@ -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")}
+73 -22
View File
@@ -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