mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 23:53:33 +08:00
fix(pricing): 目录 cache 侧字段契约哨兵与 Gemini pro 缓存写入价修正
阶梯计价数据驱动后,目录条目的字段缺失或版本拼接会直接变成计费偏差, 解析层补两道数据契约哨兵,并修正出厂快照中 Gemini pro 系的缓存写入价: - orphanCacheTierFields / warnOrphanCacheTierFields:条目带 cache 侧 above 档(含 _priority/_flex 变体与 above_1hr 形态)却没有基础价时打 WARN——cache 侧 above 档不参与计费取值,按"基础价 × 输入倍率"计,基础价 缺失即该分项按 0 计;变体缺自身基础价时沿 变体→1h→标准 回落链任一存在 即不算孤儿,与计费回落一致 - isLopsidedLongContextLadder / warnLopsidedLongContextLadders:above 档 折算出只有一侧带附加费的阶梯时打 WARN(基础价与 above 档来自不同价格 版本的形态),显式 long_context_* 字段不告警 - deriveLongContextFromAboveTierFields 注释改为陈述契约:cache above 档 须恰为基础价 × 输入倍率 - 出厂快照 6 个 Gemini pro 条目补 cache_creation_input_token_cost(= 标准 输入价,Google 对缓存写入不另收费)、above_200k 改为输入 above 价、带 priority 输入价的 4 条补 priority 孪生,与 model-price-repo 数据修正同值 测试: - 解析层:孤儿字段 WARN(含 priority/1h 孤儿正例、1h 回落 5m 反例)、单侧 阶梯 WARN、出厂快照契约(全条目无孤儿字段、无单侧阶梯、Gemini pro 缓存 写入价 = 输入价) - 用量契约:extractGeminiUsage 与 Antigravity 非流式/流式转换均不产生 cache_creation token(Gemini usageMetadata 无该类别) - 计费:gemini-2.5-pro 阶梯对 cache_read / cache_creation 分项生效,阈值 计入缓存写入 token;阶梯表场景与夹具同步为现行目录值
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 在两层数据合并后
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user