Files
sub2api/backend/internal/service/billing_token_cost_request_test.go
T
feeeei e2cfaa46e7 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;阶梯表场景与夹具同步为现行目录值
2026-09-01 01:26:55 +08:00

219 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
// newTokenCostTestEnv 构造带渠道定价的计费环境:group 100 挂一个渠道,定价由 pricing 指定。
func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelModelPricing, catalog *PricingService) (*BillingService, *ModelPricingResolver) {
t.Helper()
repo := &mockChannelRepository{
listAllFn: func(_ context.Context) ([]Channel, error) {
return []Channel{{
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{100}, ModelPricing: pricing,
}}, nil
},
getGroupPlatformsFn: func(_ context.Context, _ []int64) (map[int64]string, error) {
return map[int64]string{100: groupPlatform}, nil
},
}
cs := NewChannelService(repo, nil, nil, nil)
bs := NewBillingService(&config.Config{}, catalog)
return bs, NewModelPricingResolver(cs, bs)
}
// geminiCatalogStub 无阶梯字段的 gemini 目录条目(用于验证"无数据即无阶梯")。
func geminiCatalogStub() *PricingService {
return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gemini-2.5-pro": {
Mode: "chat",
InputCostPerToken: 1.25e-6,
OutputCostPerToken: 10e-6,
CacheReadInputTokenCost: 0.3125e-6,
},
})
}
// 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": 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}
}`
func geminiLadderCatalogStub(t *testing.T) *PricingService {
t.Helper()
return newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON)
}
// 渠道平价之上叠加目录阶梯:与分组价卡/OpenAI 渠道价的既有语义一致,
// 超阈值整单按渠道价 × 目录倍率。
func TestCalculateTokenCostForRequest_ChannelFlatPriceStacksCatalogLadder(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6),
}}, 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, PricingSourceChannel, resolved.Source)
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
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)
require.InDelta(t, 300000*10e-6*2, got.InputCost, 1e-9)
require.InDelta(t, 1000*40e-6*1.5, got.OutputCost, 1e-9)
require.True(t, got.LongContextBillingApplied)
}
// 渠道配置了定价区间时以渠道区间为准:目录阶梯(倍率)不再叠加。
func TestCalculateTokenCostForRequest_ChannelIntervalsOverrideCatalogLadder(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6)}},
}}, 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, PricingSourceChannel, resolved.Source)
require.NotEmpty(t, resolved.Intervals)
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
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)
require.InDelta(t, 300000*10e-6, got.InputCost, 1e-9)
require.InDelta(t, 1000*40e-6, got.OutputCost, 1e-9)
require.False(t, got.LongContextBillingApplied)
}
// 目录阶梯跟随分组长上下文开关;开启时为整单换档(输入 ×2、输出 ×1.5)。
func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t))
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
for _, enabled := range []bool{true, false} {
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled}
gid := group.ID
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
require.Equal(t, PricingSourceLiteLLM, resolved.Source)
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)
if enabled {
// 300K × 1.25e-6 × 2 = 0.75;1000 × 10e-6 × 1.5 = 0.015
require.InDelta(t, 0.765, got.ActualCost, 1e-9)
require.True(t, got.LongContextBillingApplied)
} else {
require.InDelta(t, 0.385, got.ActualCost, 1e-9)
require.False(t, got.LongContextBillingApplied)
}
}
}
// 目录阶梯对缓存分项同样生效: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())
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})
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
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)
require.InDelta(t, 0.385, got.ActualCost, 1e-9)
require.False(t, got.LongContextBillingApplied)
}
func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true}
gid := group.ID
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gpt-5.4", GroupID: &gid, Group: group})
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: context.Background(), Model: "gpt-5.4", Group: group, Tokens: tokens, RateMultiplier: 1,
Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
want, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "gpt-5.4", GroupID: &gid, Group: group, Tokens: tokens,
RequestCount: 1, RateMultiplier: 1, Resolver: resolver,
})
require.NoError(t, err)
require.Equal(t, want, got)
// 目录阶梯:超 272K 整单输入 ×2
require.InDelta(t, 300000*2.5e-6*2, got.InputCost, 1e-9)
require.True(t, got.LongContextBillingApplied)
}
func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) {
bs := NewBillingService(&config.Config{}, nil)
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10}
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{Model: "gpt-5.4", Tokens: tokens, RateMultiplier: 1})
require.NoError(t, err)
want, err := bs.CalculateCost("gpt-5.4", tokens, 1)
require.NoError(t, err)
require.Equal(t, want, got)
}