billing_token_cost_request_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. "github.com/stretchr/testify/require"
  8. )
  9. // newTokenCostTestEnv 构造带渠道定价的计费环境:group 100 挂一个渠道,定价由 pricing 指定。
  10. func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelModelPricing, catalog *PricingService) (*BillingService, *ModelPricingResolver) {
  11. t.Helper()
  12. repo := &mockChannelRepository{
  13. listAllFn: func(_ context.Context) ([]Channel, error) {
  14. return []Channel{{
  15. ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{100}, ModelPricing: pricing,
  16. }}, nil
  17. },
  18. getGroupPlatformsFn: func(_ context.Context, _ []int64) (map[int64]string, error) {
  19. return map[int64]string{100: groupPlatform}, nil
  20. },
  21. }
  22. cs := NewChannelService(repo, nil, nil, nil, nil)
  23. bs := NewBillingService(&config.Config{}, catalog)
  24. return bs, NewModelPricingResolver(cs, bs)
  25. }
  26. // geminiCatalogStub 无阶梯字段的 gemini 目录条目(用于验证"无数据即无阶梯")。
  27. func geminiCatalogStub() *PricingService {
  28. return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
  29. "gemini-2.5-pro": {
  30. Mode: "chat",
  31. InputCostPerToken: 1.25e-6,
  32. OutputCostPerToken: 10e-6,
  33. CacheReadInputTokenCost: 0.3125e-6,
  34. },
  35. })
  36. }
  37. // geminiLadderCatalogJSON 镜像真实目录的 gemini-2.5-pro 条目(含 cache_creation 基础价修正后的
  38. // 数值):above_200k 绝对价由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5;cache 侧 above 档
  39. // 恰为基础价 × 输入倍率(缓存写入按标准输入价,Google 对缓存写入不另收费)。
  40. const geminiLadderCatalogJSON = `{
  41. "gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
  42. "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
  43. "cache_read_input_token_cost": 1.25e-07,
  44. "cache_creation_input_token_cost": 1.25e-06,
  45. "input_cost_per_token_above_200k_tokens": 2.5e-06,
  46. "output_cost_per_token_above_200k_tokens": 1.5e-05,
  47. "cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
  48. "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06}
  49. }`
  50. func geminiLadderCatalogStub(t *testing.T) *PricingService {
  51. t.Helper()
  52. return newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON)
  53. }
  54. // 渠道平价之上叠加目录阶梯:与分组价卡/OpenAI 渠道价的既有语义一致,
  55. // 超阈值整单按渠道价 × 目录倍率。
  56. func TestCalculateTokenCostForRequest_ChannelFlatPriceStacksCatalogLadder(t *testing.T) {
  57. bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
  58. Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
  59. InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6),
  60. }}, geminiLadderCatalogStub(t))
  61. group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
  62. gid := group.ID
  63. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
  64. require.Equal(t, PricingSourceChannel, resolved.Source)
  65. tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
  66. got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  67. Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
  68. Resolver: resolver, Resolved: resolved,
  69. })
  70. require.NoError(t, err)
  71. require.InDelta(t, 300000*10e-6*2, got.InputCost, 1e-9)
  72. require.InDelta(t, 1000*40e-6*1.5, got.OutputCost, 1e-9)
  73. require.True(t, got.LongContextBillingApplied)
  74. }
  75. // 渠道配置了定价区间时以渠道区间为准:目录阶梯(倍率)不再叠加。
  76. func TestCalculateTokenCostForRequest_ChannelIntervalsOverrideCatalogLadder(t *testing.T) {
  77. bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
  78. Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
  79. Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6)}},
  80. }}, geminiLadderCatalogStub(t))
  81. group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
  82. gid := group.ID
  83. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
  84. require.Equal(t, PricingSourceChannel, resolved.Source)
  85. require.NotEmpty(t, resolved.Intervals)
  86. tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
  87. got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  88. Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
  89. Resolver: resolver, Resolved: resolved,
  90. })
  91. require.NoError(t, err)
  92. require.InDelta(t, 300000*10e-6, got.InputCost, 1e-9)
  93. require.InDelta(t, 1000*40e-6, got.OutputCost, 1e-9)
  94. require.False(t, got.LongContextBillingApplied)
  95. }
  96. // 目录阶梯跟随分组长上下文开关;开启时为整单换档(输入 ×2、输出 ×1.5)。
  97. func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing.T) {
  98. bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t))
  99. tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
  100. for _, enabled := range []bool{true, false} {
  101. group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled}
  102. gid := group.ID
  103. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
  104. require.Equal(t, PricingSourceLiteLLM, resolved.Source)
  105. got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  106. Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
  107. Resolver: resolver, Resolved: resolved,
  108. })
  109. require.NoError(t, err)
  110. if enabled {
  111. // 300K × 1.25e-6 × 2 = 0.75;1000 × 10e-6 × 1.5 = 0.015
  112. require.InDelta(t, 0.765, got.ActualCost, 1e-9)
  113. require.True(t, got.LongContextBillingApplied)
  114. } else {
  115. require.InDelta(t, 0.385, got.ActualCost, 1e-9)
  116. require.False(t, got.LongContextBillingApplied)
  117. }
  118. }
  119. }
  120. // 目录阶梯对缓存分项同样生效:cache_read / cache_creation 随输入倍率整单换档,
  121. // 阈值判定计入全部输入侧 token(input + cache_creation + cache_read)。
  122. func TestCalculateTokenCostForRequest_GeminiLadderAppliesToCacheItems(t *testing.T) {
  123. bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t))
  124. group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
  125. gid := group.ID
  126. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
  127. require.Equal(t, PricingSourceLiteLLM, resolved.Source)
  128. calc := func(tokens UsageTokens) *CostBreakdown {
  129. got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  130. Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
  131. Resolver: resolver, Resolved: resolved,
  132. })
  133. require.NoError(t, err)
  134. return got
  135. }
  136. // 输入侧合计 90K + 100K + 20K = 210K > 200K:所有分项按高档计。
  137. // 不计 cache_creation 时只有 110K,不会过阈值——用例同时守住"缓存写入 token 计入阈值"。
  138. above := calc(UsageTokens{InputTokens: 90000, CacheCreationTokens: 100000, CacheReadTokens: 20000, OutputTokens: 1000})
  139. require.True(t, above.LongContextBillingApplied)
  140. require.InDelta(t, 90000*1.25e-6*2, above.InputCost, 1e-9)
  141. require.InDelta(t, 100000*1.25e-6*2, above.CacheCreationCost, 1e-9)
  142. require.InDelta(t, 20000*1.25e-7*2, above.CacheReadCost, 1e-9)
  143. require.InDelta(t, 1000*1e-5*1.5, above.OutputCost, 1e-9)
  144. // 输入侧合计 50K + 100K + 40K = 190K ≤ 200K:按基础价计
  145. below := calc(UsageTokens{InputTokens: 50000, CacheCreationTokens: 100000, CacheReadTokens: 40000, OutputTokens: 1000})
  146. require.False(t, below.LongContextBillingApplied)
  147. require.InDelta(t, 50000*1.25e-6, below.InputCost, 1e-9)
  148. require.InDelta(t, 100000*1.25e-6, below.CacheCreationCost, 1e-9)
  149. require.InDelta(t, 40000*1.25e-7, below.CacheReadCost, 1e-9)
  150. }
  151. // 目录条目没有阶梯字段时,开关开启也不产生阶梯。
  152. func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) {
  153. bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub())
  154. group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
  155. gid := group.ID
  156. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
  157. tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
  158. got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  159. Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
  160. Resolver: resolver, Resolved: resolved,
  161. })
  162. require.NoError(t, err)
  163. require.InDelta(t, 0.385, got.ActualCost, 1e-9)
  164. require.False(t, got.LongContextBillingApplied)
  165. }
  166. func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) {
  167. bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
  168. group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true}
  169. gid := group.ID
  170. tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
  171. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gpt-5.4", GroupID: &gid, Group: group})
  172. got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  173. Ctx: context.Background(), Model: "gpt-5.4", Group: group, Tokens: tokens, RateMultiplier: 1,
  174. Resolver: resolver, Resolved: resolved,
  175. })
  176. require.NoError(t, err)
  177. want, err := bs.CalculateCostUnified(CostInput{
  178. Ctx: context.Background(), Model: "gpt-5.4", GroupID: &gid, Group: group, Tokens: tokens,
  179. RequestCount: 1, RateMultiplier: 1, Resolver: resolver,
  180. })
  181. require.NoError(t, err)
  182. require.Equal(t, want, got)
  183. // 目录阶梯:超 272K 整单输入 ×2
  184. require.InDelta(t, 300000*2.5e-6*2, got.InputCost, 1e-9)
  185. require.True(t, got.LongContextBillingApplied)
  186. }
  187. func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) {
  188. bs := NewBillingService(&config.Config{}, nil)
  189. tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10}
  190. got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{Model: "gpt-5.4", Tokens: tokens, RateMultiplier: 1})
  191. require.NoError(t, err)
  192. want, err := bs.CalculateCost("gpt-5.4", tokens, 1)
  193. require.NoError(t, err)
  194. require.Equal(t, want, got)
  195. }
  196. func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *testing.T) {
  197. bs := NewBillingService(&config.Config{}, nil)
  198. tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10}
  199. standard, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  200. Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "xhigh",
  201. })
  202. require.NoError(t, err)
  203. max, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  204. Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "max",
  205. })
  206. require.NoError(t, err)
  207. require.InDelta(t, standard.TotalCost*3, max.TotalCost, 1e-12)
  208. require.InDelta(t, standard.ActualCost*3, max.ActualCost, 1e-12)
  209. require.InDelta(t, standard.InputCost*3, max.InputCost, 1e-12)
  210. require.InDelta(t, standard.OutputCost*3, max.OutputCost, 1e-12)
  211. }
  212. func TestCalculateTokenCostForRequest_ChannelOverridesFable51MaxEffortMultiplier(t *testing.T) {
  213. configured := 1.5
  214. bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, []ChannelModelPricing{{
  215. Platform: PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: BillingModeToken,
  216. InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(50e-6),
  217. MaxReasoningEffortMultiplier: &configured,
  218. }}, nil)
  219. group := &Group{ID: 100, Platform: PlatformAnthropic}
  220. gid := group.ID
  221. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-fable-5-1", GroupID: &gid, Group: group})
  222. got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  223. Ctx: context.Background(), Model: "claude-fable-5-1", Group: group,
  224. Tokens: UsageTokens{InputTokens: 1000}, RateMultiplier: 1, ReasoningEffort: "max",
  225. Resolver: resolver, Resolved: resolved,
  226. })
  227. require.NoError(t, err)
  228. require.InDelta(t, 1000*10e-6*configured, got.TotalCost, 1e-12)
  229. require.InDelta(t, got.TotalCost, got.ActualCost, 1e-12)
  230. }