diff --git a/backend/internal/pkg/antigravity/usage_contract_test.go b/backend/internal/pkg/antigravity/usage_contract_test.go new file mode 100644 index 0000000000..832bffbfb2 --- /dev/null +++ b/backend/internal/pkg/antigravity/usage_contract_test.go @@ -0,0 +1,40 @@ +//go:build unit + +package antigravity + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入的 token 类别: +// 两条转换路径产出的 Claude usage 中 cache_creation_input_tokens 恒为 0,缓存命中计入 +// cache_read_input_tokens 并从 input_tokens 中扣除。 +func TestGeminiUsageMapping_NoCacheCreationTokens(t *testing.T) { + const geminiBody = `{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}],` + + `"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"cachedContentTokenCount":30,"thoughtsTokenCount":5}}` + + t.Run("non-stream", func(t *testing.T) { + _, usage, err := TransformGeminiToClaude([]byte(geminiBody), "gemini-3.1-pro-preview") + require.NoError(t, err) + require.NotNil(t, usage) + require.Equal(t, 70, usage.InputTokens) + require.Equal(t, 25, usage.OutputTokens) + require.Equal(t, 30, usage.CacheReadInputTokens) + require.Zero(t, usage.CacheCreationInputTokens) + }) + + t.Run("stream", func(t *testing.T) { + p := NewStreamingProcessor("gemini-3.1-pro-preview") + out := p.ProcessLine(`data: {"response":` + geminiBody + `}`) + require.True(t, strings.Contains(string(out), `"message_start"`)) + require.NotContains(t, string(out), `"cache_creation_input_tokens"`, "message_start 的 usage 不应携带缓存写入分项") + _, usage := p.Finish() + require.NotNil(t, usage) + require.Equal(t, 70, usage.InputTokens) + require.Equal(t, 30, usage.CacheReadInputTokens) + require.Zero(t, usage.CacheCreationInputTokens) + }) +} diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index 77a0195cf4..dfe43f1265 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -237,8 +237,9 @@ func scheduleScenarios() []scheduleScenario { group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) - requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6)) - requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), nil, p(0.625e-6)) + // 缓存写入按标准输入价,与 input 一同整单换档 + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), p(1.25e-6), p(0.125e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) }, }, { diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index ccb5b73642..9d282de061 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -40,15 +40,18 @@ func geminiCatalogStub() *PricingService { }) } -// geminiLadderCatalogJSON 镜像真实目录的 gemini pro 条目:above_200k 绝对价, -// 由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5。 +// geminiLadderCatalogJSON 镜像真实目录的 gemini-2.5-pro 条目(含 cache_creation 基础价修正后的 +// 数值):above_200k 绝对价由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5;cache 侧 above 档 +// 恰为基础价 × 输入倍率(缓存写入按标准输入价,Google 对缓存写入不另收费)。 const geminiLadderCatalogJSON = `{ "gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "cache_read_input_token_cost_above_200k_tokens": 6.25e-07} + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06} }` func geminiLadderCatalogStub(t *testing.T) *PricingService { @@ -129,6 +132,41 @@ func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing } } +// 目录阶梯对缓存分项同样生效:cache_read / cache_creation 随输入倍率整单换档, +// 阈值判定计入全部输入侧 token(input + cache_creation + cache_read)。 +func TestCalculateTokenCostForRequest_GeminiLadderAppliesToCacheItems(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t)) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceLiteLLM, resolved.Source) + + calc := func(tokens UsageTokens) *CostBreakdown { + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + return got + } + + // 输入侧合计 90K + 100K + 20K = 210K > 200K:所有分项按高档计。 + // 不计 cache_creation 时只有 110K,不会过阈值——用例同时守住"缓存写入 token 计入阈值"。 + above := calc(UsageTokens{InputTokens: 90000, CacheCreationTokens: 100000, CacheReadTokens: 20000, OutputTokens: 1000}) + require.True(t, above.LongContextBillingApplied) + require.InDelta(t, 90000*1.25e-6*2, above.InputCost, 1e-9) + require.InDelta(t, 100000*1.25e-6*2, above.CacheCreationCost, 1e-9) + require.InDelta(t, 20000*1.25e-7*2, above.CacheReadCost, 1e-9) + require.InDelta(t, 1000*1e-5*1.5, above.OutputCost, 1e-9) + + // 输入侧合计 50K + 100K + 40K = 190K ≤ 200K:按基础价计 + below := calc(UsageTokens{InputTokens: 50000, CacheCreationTokens: 100000, CacheReadTokens: 40000, OutputTokens: 1000}) + require.False(t, below.LongContextBillingApplied) + require.InDelta(t, 50000*1.25e-6, below.InputCost, 1e-9) + require.InDelta(t, 100000*1.25e-6, below.CacheCreationCost, 1e-9) + require.InDelta(t, 40000*1.25e-7, below.CacheReadCost, 1e-9) +} + // 目录条目没有阶梯字段时,开关开启也不产生阶梯。 func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) { bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index e9ee74e3be..c6f5a46d08 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -898,6 +898,11 @@ func TestExtractGeminiUsage(t *testing.T) { if got.CacheReadInputTokens != tt.wantUsage.CacheReadInputTokens { t.Errorf("CacheReadInputTokens: 期望 %d,实际 %d", tt.wantUsage.CacheReadInputTokens, got.CacheReadInputTokens) } + // Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入 + // 的 token 类别:cache_creation_input_tokens 恒为 0,计费侧不会产生缓存创建分项。 + if got.CacheCreationInputTokens != 0 { + t.Errorf("CacheCreationInputTokens: 期望 0,实际 %d", got.CacheCreationInputTokens) + } }) } } diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index f3b9339cdc..826f6a7803 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -30,7 +30,12 @@ var ( // aboveTierPricePattern 匹配 LiteLLM 长上下文绝对价字段名 // (input_cost_per_token_above_272k_tokens / output_cost_per_token_above_200k_tokens 等)。 // 带 _flex/_priority 服务档后缀的变体与 cache 侧字段不参与阈值/倍率折算。 - aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`) + aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`) + // cacheTierPricePattern 匹配 cache 侧的长上下文绝对价字段名 + // (cache_creation_input_token_cost_above_200k_tokens、cache_read_input_token_cost_above_272k_tokens_priority、 + // cache_creation_input_token_cost_above_1hr_above_200k_tokens 等)。 + // 组 1 为基础价字段名主干,组 2 为 1h 缓存时长段(可为空),组 3 为服务档后缀(可为空)。 + cacheTierPricePattern = regexp.MustCompile(`^(cache_(?:creation|read)_input_token_cost)(_above_1hr)?_above_\d+k_tokens((?:_[a-z]+)?)$`) openAIGPT54FallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 2.5e-06, // $2.5 per MTok OutputCostPerToken: 1.5e-05, // $15 per MTok @@ -425,6 +430,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel result := make(map[string]*LiteLLMModelPricing) skipped := 0 + var orphanCacheTiers, lopsidedLadders []string for modelName, rawEntry := range rawData { // 跳过 sample_spec 等文档条目 @@ -503,6 +509,13 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel entry.LongContextOutputCostMultiplier != nil if !hasExplicitLongContext { deriveLongContextFromAboveTierFields(rawEntry, pricing) + if isLopsidedLongContextLadder(pricing) { + lopsidedLadders = append(lopsidedLadders, fmt.Sprintf("%s(input x%.2f, output x%.2f)", modelName, + pricing.LongContextInputCostMultiplier, pricing.LongContextOutputCostMultiplier)) + } + } + if orphans := orphanCacheTierFields(rawEntry); len(orphans) > 0 { + orphanCacheTiers = append(orphanCacheTiers, modelName+"("+strings.Join(orphans, ",")+")") } result[modelName] = pricing @@ -511,6 +524,8 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if skipped > 0 { logger.LegacyPrintf("service.pricing", "[Pricing] Skipped %d invalid entries", skipped) } + warnOrphanCacheTierFields(orphanCacheTiers) + warnLopsidedLongContextLadders(lopsidedLadders) if len(result) == 0 { return nil, fmt.Errorf("no valid pricing entries found") @@ -524,9 +539,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel // 倍率 = above 价 ÷ 基础价。条目显式携带任一 long_context_* 字段(含显式 0)时由 // 调用方跳过折算,以显式配置为准——显式写 threshold=0 或 multiplier=1 均可关闭该 // 模型的阶梯。多个阈值并存时取最小阈值。 -// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算; -// 现网 OpenAI/Claude 条目(含 5m/1h 分档)的 cache above 档均恰为基础价 × 输入倍率, -// Gemini pro 系存在无基础价的孤儿 cache_creation above 字段(数据缺陷,计费两侧均按 0)。 +// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算: +// 目录条目的 cache above 档须恰为基础价 × 输入倍率;缺基础价的 cache above 字段 +// 无法参与计费,由 orphanCacheTierFields 哨兵告警。 func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *LiteLLMModelPricing) { if pricing == nil || pricing.LongContextInputTokenThreshold > 0 || @@ -594,6 +609,77 @@ func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *Lit pricing.LongContextOutputCostMultiplier = outputMultiplier } +// isLopsidedLongContextLadder 判断折算出的阶梯是否只有一侧带附加费。官方阶梯(OpenAI、 +// Google、Anthropic、xAI)都同时抬高 input 与 output;单侧附加费意味着条目的基础价与 +// above 档来自不同价格版本(如基础价被手工 pin、above 档随上游更新),折算出的倍率失真。 +func isLopsidedLongContextLadder(pricing *LiteLLMModelPricing) bool { + if pricing == nil || pricing.LongContextInputTokenThreshold <= 0 { + return false + } + return (pricing.LongContextInputCostMultiplier > 1) != (pricing.LongContextOutputCostMultiplier > 1) +} + +// warnLopsidedLongContextLadders 对单侧附加费的折算阶梯打 WARN:应成组修正该条目的 +// 基础价与 above 档(目录或 pricing.override_file)。 +func warnLopsidedLongContextLadders(entries []string) { + if len(entries) == 0 { + return + } + sort.Strings(entries) + total := len(entries) + if total > 20 { + entries = append(entries[:20], "...") + } + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: %d model(s) derive a one-sided long-context ladder (surcharge on only input or only output); base prices and above-tier prices likely come from different price versions: %s", total, strings.Join(entries, ", ")) +} + +// orphanCacheTierFields 返回条目中没有对应基础价的 cache 侧 above 档字段名。 +// cache 侧 above 档不参与计费取值,计费按"基础价 × 输入倍率";基础价缺失或为 0 时, +// 该缓存分项在整个阶梯上都按 0 计。计费对变体有回落:服务档变体(_priority/_flex) +// 缺自身基础价时用标准基础价,1h 缓存写入缺 above_1hr 价时全部按 5m 价——因此沿 +// 回落链任一基础价存在即不算孤儿。 +func orphanCacheTierFields(rawEntry json.RawMessage) []string { + if !bytes.Contains(rawEntry, []byte("_above_")) { + return nil + } + var fields map[string]any + if err := json.Unmarshal(rawEntry, &fields); err != nil { + return nil + } + positive := func(key string) bool { + price, ok := fields[key].(float64) + return ok && price > 0 + } + var orphans []string + for key := range fields { + m := cacheTierPricePattern.FindStringSubmatch(key) + if m == nil || !positive(key) { + continue + } + stem, hourly, tier := m[1], m[2], m[3] + if positive(stem+hourly+tier) || positive(stem+hourly) || positive(stem+tier) || positive(stem) { + continue + } + orphans = append(orphans, key) + } + sort.Strings(orphans) + return orphans +} + +// warnOrphanCacheTierFields 对带 cache 侧 above 档却没有基础价的条目打 WARN: +// 该缓存分项按 0 计费,目录或 pricing.override_file 补上基础价即可消除。 +func warnOrphanCacheTierFields(entries []string) { + if len(entries) == 0 { + return + } + sort.Strings(entries) + total := len(entries) + if total > 20 { + entries = append(entries[:20], "...") + } + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: %d model(s) carry cache above-tier prices without a base cache price; that cache item bills at $0 until the catalog/override supplies the base: %s", total, strings.Join(entries, ", ")) +} + // applyPricingOverrides 把 override 文件的条目逐字段修补进原始目录数据。目录与回退 // 文件的解析都经过 parsePricingData,因此 override 是最高优先级的数据源。这里只修补 // 已存在的条目:目录/回退里都没有的模型由 mergeOverrideOnlyModels 在两层数据合并后 diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 96cc63ba55..a02c74fe72 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -827,6 +827,126 @@ func TestParsePricingData_ExplicitZeroThresholdDisablesLadder(t *testing.T) { require.Zero(t, data["gpt-5.5"].LongContextInputCostMultiplier) } +// cache 侧 above 档随输入倍率计费、不单独折算;缺基础价的 cache above 字段无法参与计费, +// 该缓存分项按 0 计,属于数据契约违规,必须有哨兵 WARN。服务档变体缺基础价时回落 +// 标准基础价,不算孤儿。 +func TestParsePricingData_WarnsOrphanCacheTierFields(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "gemini-orphan": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07}, + "gemini-complete": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06}, + "priority-variant-without-own-base": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06}, + "priority-variant-orphan": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05}, + "hourly-tier-with-5m-base": {"litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05}, + "hourly-tier-orphan": {"litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05} + }`)) + require.NoError(t, err) + + require.Equal(t, 200000, data["gemini-orphan"].LongContextInputTokenThreshold, "孤儿 cache 字段不影响 input/output 阶梯折算") + require.Zero(t, data["gemini-orphan"].CacheCreationInputTokenCost) + require.InDelta(t, 1.25e-6, data["gemini-complete"].CacheCreationInputTokenCost, 1e-12) + + require.True(t, logSink.ContainsMessageAtLevel("gemini-orphan(cache_creation_input_token_cost_above_200k_tokens)", "warn")) + require.True(t, logSink.ContainsMessage("priority-variant-orphan(cache_creation_input_token_cost_above_272k_tokens_priority)")) + require.True(t, logSink.ContainsMessage("hourly-tier-orphan(cache_creation_input_token_cost_above_1hr_above_200k_tokens)")) + require.False(t, logSink.ContainsMessage("gemini-complete")) + require.False(t, logSink.ContainsMessage("priority-variant-without-own-base")) + require.False(t, logSink.ContainsMessage("hourly-tier-with-5m-base"), "1h 档缺 above_1hr 基础价时计费回落 5m 价,不算孤儿") +} + +// 基础价与 above 档来自不同价格版本时(如基础价被手工 pin、above 档随上游更新)会折算出 +// 只有一侧带附加费的阶梯,必须有哨兵 WARN;显式 long_context_* 字段是部署方意图,不告警。 +func TestParsePricingData_WarnsLopsidedLongContextLadder(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "mixed-versions": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "input_cost_per_token_above_272k_tokens": 8e-06, + "output_cost_per_token_above_272k_tokens": 3e-05}, + "consistent": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 4e-06, "output_cost_per_token": 2e-05, + "input_cost_per_token_above_272k_tokens": 8e-06, + "output_cost_per_token_above_272k_tokens": 3e-05}, + "explicit-input-only": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 4e-06, "output_cost_per_token": 2e-05, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2} + }`)) + require.NoError(t, err) + + require.Equal(t, 272000, data["mixed-versions"].LongContextInputTokenThreshold, "单侧阶梯仍按折算结果计费,只告警不丢弃") + require.InDelta(t, 1.6, data["mixed-versions"].LongContextInputCostMultiplier, 1e-12) + require.True(t, logSink.ContainsMessageAtLevel("mixed-versions(input x1.60, output x1.00)", "warn")) + require.False(t, logSink.ContainsMessage("consistent")) + require.False(t, logSink.ContainsMessage("explicit-input-only")) +} + +// 出厂回退快照必须满足数据契约:没有孤儿 cache above 字段、没有单侧阶梯,且 Gemini pro 系的 +// 缓存写入基础价等于标准输入价(含 priority 变体)。快照是随目录同步刷新的文本,这里防止刷新时静默回退。 +func TestDefaultCatalogSnapshot_CacheTierContract(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + var rawEntries map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &rawEntries)) + for name, raw := range rawEntries { + require.Empty(t, orphanCacheTierFields(raw), "快照条目 %s 带孤儿 cache above 字段", name) + } + + svc := &PricingService{} + data, err := svc.parsePricingData(body) + require.NoError(t, err) + require.False(t, logSink.ContainsMessage("carry cache above-tier prices"), "快照不应触发孤儿 cache 字段哨兵") + require.False(t, logSink.ContainsMessage("one-sided long-context ladder"), "快照不应触发单侧阶梯哨兵") + for _, model := range []string{ + "gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3.1-pro-preview", + "gemini-3.1-pro-high", "gemini-3.1-pro-low", "gemini-3.1-pro-preview-customtools", + } { + pricing := data[model] + require.NotNil(t, pricing, model) + require.Positive(t, pricing.InputCostPerToken, model) + require.InDelta(t, pricing.InputCostPerToken, pricing.CacheCreationInputTokenCost, 1e-15, "%s 缓存写入基础价应等于标准输入价", model) + require.Equal(t, 200000, pricing.LongContextInputTokenThreshold, model) + if pricing.InputCostPerTokenPriority > 0 { + require.InDelta(t, pricing.InputCostPerTokenPriority, pricing.CacheCreationInputTokenCostPriority, 1e-15, "%s priority 缓存写入价应等于 priority 输入价", model) + } + } +} + // F1:显式字段只写了一侧倍率时,缺失侧按 1 计而不是乘 0 免费。 func TestCalculateCost_PartialLongContextMultiplierDefaultsToOne(t *testing.T) { tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000} diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 94790cdba1..7fea808561 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -1414,7 +1414,8 @@ ] }, "gemini-2.5-pro": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06, "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, @@ -1702,7 +1703,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3-pro-preview": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2031,7 +2035,10 @@ "supports_web_search": true }, "gemini-3.1-pro-high": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2095,7 +2102,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-low": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2159,7 +2169,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2223,7 +2236,8 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview-customtools": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "input_cost_per_token": 2e-06,