feat(pricing): Google's Gemini rates, with a dated generation for the announced step

This commit is contained in:
esengine committed 2026-10-17 19:57:22 -07:00
1 parent 33219e58c5
commit 12fb14abb6
9 files changed
+149 -10

No files matched your search

+1
View File
@@ -48,6 +48,7 @@ import (
// Kinds register from init, so a binary builds only what it links. Without
// these every Anthropic model answers "unknown kind" at switch time.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
)
+1
View File
@@ -12,6 +12,7 @@ import (
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
_ "reasonix/internal/tools/builtin"
+3
View File
@@ -46,6 +46,9 @@ var protocols = []Protocol{
{Kind: "openai", Answers: AnswersChat, Discovery: "openai", ReasoningParams: true, EffortField: "reasoning_effort", EffortUnder: []string{ReasoningProtocolOpenAI}, EffortUnresolved: true},
{Kind: "responses", Answers: AnswersChat, Discovery: "openai", ServerWebSearch: true, ReasoningParams: true, StatefulContinuation: true, EffortField: "reasoning.effort"},
{Kind: "anthropic", Answers: AnswersChat, Discovery: "anthropic", ServerWebSearch: true, EffortField: "output_config.effort", EffortUnder: []string{ReasoningProtocolAnthropic}},
// No Discovery: Google's model listing shape is not read for this wire yet,
// so a Gemini model id is declared rather than probed for.
{Kind: "gemini", Answers: AnswersChat, EffortField: "generation_config.thinking_level"},
// No Discovery: the decision API has no model listing, so the model id is
// declared rather than probed for.
{Kind: "typesafe", Answers: AnswersDecision},
@@ -10,6 +10,7 @@ func TestProtocolsDeclareWhereEffortLands(t *testing.T) {
"openai": "reasoning_effort",
"responses": "reasoning.effort",
"anthropic": "output_config.effort",
"gemini": "generation_config.thinking_level",
"typesafe": "",
}
seen := 0
@@ -6,6 +6,7 @@ import (
"reasonix/internal/contract/provider"
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
)
+12 -7
View File
@@ -11,7 +11,7 @@ import (
// CatalogEntry is one official list price for a model in a billing currency.
type CatalogEntry struct {
Provider string // deepseek | longcat | mimo
Provider string // deepseek | longcat | mimo | google
Model string
Currency string // ISO billing currency for this row
CacheHit float64
@@ -76,6 +76,9 @@ const deepseekCheckedOn = "2026-09-13"
// mimoCheckedOn is when the V2.6 rows were read off the vendor's own docs.
const mimoCheckedOn = "2026-10-06"
// googleCheckedOn is when the Gemini rows were read off Google's pricing page.
const googleCheckedOn = "2026-10-17"
// CatalogSourceURLs document public pricing pages.
const (
DocDeepSeekPricing = "https://api-docs.deepseek.com/quick_start/pricing"
@@ -84,6 +87,7 @@ const (
DocLongCatPricingCNY = "https://longcat.chat/platform/docs/zh/pricing/long-cat-2.0"
DocMiMoPAYG = "https://mimo.mi.com/docs/price/pay-as-you-go"
DocMiMoTokenPlan = "https://platform.xiaomimimo.com/token-plan"
DocGeminiPricing = "https://ai.google.dev/gemini-api/docs/pricing"
)
// officialEndpointHosts maps a vendor's own API hostnames to the catalog
@@ -91,12 +95,13 @@ const (
// or proxy bills on its own terms even when it serves the same model, and an
// entry merely named after a vendor is not evidence of the vendor's endpoint.
var officialEndpointHosts = map[string]string{
"api.deepseek.com": "deepseek",
"api.longcat.chat": "longcat",
"api.xiaomimimo.com": "mimo",
"token-plan-cn.xiaomimimo.com": "mimo",
"token-plan-sgp.xiaomimimo.com": "mimo",
"token-plan-ams.xiaomimimo.com": "mimo",
"api.deepseek.com": "deepseek",
"api.longcat.chat": "longcat",
"api.xiaomimimo.com": "mimo",
"generativelanguage.googleapis.com": "google",
"token-plan-cn.xiaomimimo.com": "mimo",
"token-plan-sgp.xiaomimimo.com": "mimo",
"token-plan-ams.xiaomimimo.com": "mimo",
}
// OfficialProviderForEndpoint returns the catalog provider whose official
@@ -0,0 +1,68 @@
package pricing
import "testing"
// https://ai.google.dev/gemini-api/docs/pricing (read 2026-10-17): per 1M tokens,
// paid tier. gemini-3.8-flash is 0.75 in / 3.75 out / 0.075 cached through
// 2026-12-31 and 1.50 / 7.50 / 0.15 from 2027-01-01.
func TestAGeminiRateStepIsDatedNotEdited(t *testing.T) {
was := today
defer func() { today = was }()
for _, tc := range []struct {
day string
cache, input, output float64
}{
{"2026-10-17", 0.075, 0.75, 3.75},
{"2026-12-31", 0.075, 0.75, 3.75},
{"2027-01-01", 0.15, 1.50, 7.50},
{"2028-03-01", 0.15, 1.50, 7.50},
} {
today = func() string { return tc.day }
got := CurrentRate("google", "gemini-3.8-flash", "USD")
if got == nil || got.CacheHit != tc.cache || got.Input != tc.input || got.Output != tc.output {
t.Errorf("%s: rate = %+v, want %v/%v/%v", tc.day, got, tc.cache, tc.input, tc.output)
}
}
}
func TestTheRateInForceIsNotListedAsSuperseded(t *testing.T) {
was := today
defer func() { today = was }()
today = func() string { return "2026-10-17" }
if sup := SupersededRates("google", "gemini-3.8-flash", "USD"); len(sup) != 0 {
t.Fatalf("before the step nothing is superseded, got %+v", sup)
}
today = func() string { return "2027-02-01" }
sup := SupersededRates("google", "gemini-3.8-flash", "USD")
if len(sup) != 1 || sup[0].Input != 0.75 {
t.Fatalf("after the step the intro rate is superseded, got %+v", sup)
}
if !IsOfficialCard("google", "gemini-3.8-flash", RateCard{CacheHit: 0.075, Input: 0.75, Output: 3.75, Currency: "USD"}) {
t.Fatal("a stored intro card must still read as ours after the step")
}
}
func TestGoogleModelsAreBilledAtGoogleOnlyOnGoogleHosts(t *testing.T) {
if OfficialProviderForEndpoint("https://generativelanguage.googleapis.com/v1beta") != "google" {
t.Fatal("Google's own host does not read as Google")
}
if OfficialProviderForEndpoint("https://relay.example.com/v1beta") != "" {
t.Fatal("a relay was billed at Google's list price")
}
}
func TestEveryGoogleRowNamesItsSource(t *testing.T) {
n := 0
for _, e := range OfficialCatalog() {
if e.Provider != "google" {
continue
}
n++
if e.DocURL != DocGeminiPricing || e.CheckedOn != googleCheckedOn || e.Input <= 0 || e.Output <= e.Input {
t.Errorf("row %+v", e)
}
}
if n != 4 {
t.Fatalf("%d Google rows, want 4 (gemini-4-argon shows no price)", n)
}
}
+56 -3
View File
@@ -5,6 +5,7 @@ package pricing
import (
"slices"
"strings"
"time"
)
// rateGeneration is one published pair. Peak is nil for a generation the vendor
@@ -12,6 +13,26 @@ import (
type rateGeneration struct {
Base RateCard
Peak *RateCard
// From is the first day (YYYY-MM-DD, UTC) the vendor bills this generation.
// Empty means it applies from the moment it was shipped. A vendor that
// announces a price change ahead of time gets the later generation appended
// now, with its date, rather than edited in when the day comes.
From string
}
// today is the day a dated generation is read against; a test replaces it.
var today = func() string { return time.Now().UTC().Format("2006-01-02") }
// currentIndex is the generation in force today: the last one whose date has
// come. Rows with no dated generation answer with their last, as before.
func (r officialRateRow) currentIndex() int {
day := today()
for i := len(r.Rates) - 1; i > 0; i-- {
if r.Rates[i].From == "" || r.Rates[i].From <= day {
return i
}
}
return 0
}
// officialRateRow is one vendor's model, in one currency, under one billing
@@ -89,6 +110,38 @@ var officialRates = []officialRateRow{
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.006, Input: 0.30, Output: 1.20, Currency: "USD"}}},
},
// Google bills one rate card per model; gemini-3.8-flash and gemini-3.6-flash
// double on 2027-01-01 per the page, which is why their second generation is
// dated. gemini-3.1-pro-preview bills more above 200k prompt tokens (cached
// 0.40, input 4.00, output 18.00), which one card cannot say, so the card is
// the rate up to 200k and Notes says so. gemini-4-argon shows no price.
{
Provider: "google", Model: "gemini-3.8-flash", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.075, Input: 0.75, Output: 3.75, Currency: "USD"}},
{From: "2027-01-01", Base: RateCard{CacheHit: 0.15, Input: 1.50, Output: 7.50, Currency: "USD"}},
},
},
{
Provider: "google", Model: "gemini-3.6-flash", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.075, Input: 0.75, Output: 3.75, Currency: "USD"}},
{From: "2027-01-01", Base: RateCard{CacheHit: 0.15, Input: 1.50, Output: 7.50, Currency: "USD"}},
},
},
{
Provider: "google", Model: "gemini-3.5-flash-lite", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.03, Input: 0.30, Output: 2.50, Currency: "USD"}}},
},
{
Provider: "google", Model: "gemini-3.1-pro-preview", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, Notes: "up_to_200k_prompt_tokens", CheckedOn: googleCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.20, Input: 2.00, Output: 12.00, Currency: "USD"}}},
},
// MiMo domestic PAYG; Token Plan bills the same rates as subscription_equivalent.
{
Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY",
@@ -221,7 +274,7 @@ func CurrentRate(provider, model, currency string) *RateCard {
continue
}
if len(row.Rates) > 0 {
rate := row.Rates[len(row.Rates)-1].Base
rate := row.Rates[row.currentIndex()].Base
return &rate
}
}
@@ -259,7 +312,7 @@ func SupersededRates(provider, model, currency string) []RateCard {
if row.BillingMode == BillingModeSubscriptionEquivalent || len(row.Rates) < 2 {
continue
}
for _, generation := range row.Rates[:len(row.Rates)-1] {
for _, generation := range row.Rates[:row.currentIndex()] {
out = append(out, generation.Base)
}
}
@@ -274,7 +327,7 @@ func officialCatalogRows() []CatalogEntry {
if len(row.Rates) == 0 {
continue
}
current := row.Rates[len(row.Rates)-1]
current := row.Rates[row.currentIndex()]
rows = append(rows, row.entry(row.Model, current))
if row.Provider != "deepseek" || row.Model != "deepseek-flash" {
continue
+6
View File
@@ -52,6 +52,12 @@ func sources() []source {
URL: pricing.DocLongCatPricingCNY,
Read: readLongCat,
},
{
Provider: "google", Currency: "USD",
Models: []string{"gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"},
URL: pricing.DocGeminiPricing,
Unread: "no reader yet: the page lists several tiers and a dated step per model, so a person re-reads it and moves CheckedOn",
},
{
Provider: "mimo", Currency: "CNY",
Models: []string{"mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-flash"},