model_pricing_resolver_channel_normalized_test.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. //go:build unit
  2. package service
  3. // issue #5256 回归测试:使用记录的费用统计没有按照渠道定价的价格进行计算。
  4. //
  5. // 场景:管理员在渠道定价把 gpt-5.6-luna 的输入价从官方 $0.2/M 调成 $0.4/M。
  6. // 当请求模型带 effort 后缀(gpt-5.6-luna-high)而渠道只配了基名时,渠道定价查找
  7. // 用字面名未命中,官方兜底价却会把后缀名归一化到 gpt-5.6-luna 并命中静态价
  8. // (pricing_service.go 的 gpt-5.6-luna 前缀分支),计费候选循环首个成功即返回
  9. // → 落库的是官方 0.2 而不是渠道 0.4。
  10. //
  11. // 测试走与生产一致的 populateChannelCache → OpenAIGatewayService.RecordUsage 路径,
  12. // 断言落库 UsageLog 的 InputCost。
  13. import (
  14. "context"
  15. "testing"
  16. "time"
  17. "github.com/stretchr/testify/require"
  18. )
  19. const (
  20. // 1M 输入 token 下,渠道价与官方兜底价的期望费用(USD)
  21. channelPricingExpectedChannelCost = 0.4
  22. channelPricingExpectedOfficialCost = 0.2
  23. // 用于验证「不相关的渠道配置不会被误命中」的对照价
  24. channelPricingUnrelatedCost = 0.9
  25. )
  26. // tokenPricingForModels 构造 token 计费模式的渠道定价;inputPerMillion 单位为 USD/1M token。
  27. func tokenPricingForModels(models []string, inputPerMillion float64) ChannelModelPricing {
  28. return ChannelModelPricing{
  29. Platform: PlatformOpenAI,
  30. Models: models,
  31. BillingMode: BillingModeToken,
  32. InputPrice: float64Ptr(inputPerMillion / 1e6),
  33. OutputPrice: float64Ptr(2.4e-6),
  34. CacheWritePrice: float64Ptr(0.5e-6),
  35. CacheReadPrice: float64Ptr(0.04e-6),
  36. }
  37. }
  38. func newChannelServiceWithPricings(groupID int64, pricings []ChannelModelPricing) *ChannelService {
  39. ch := Channel{
  40. ID: 1,
  41. Name: "codex-channel",
  42. Status: StatusActive,
  43. ModelPricing: pricings,
  44. GroupIDs: []int64{groupID},
  45. }
  46. cs := &ChannelService{}
  47. cs.cache.Store(populateChannelCache([]Channel{ch}, map[int64]string{groupID: PlatformOpenAI}))
  48. return cs
  49. }
  50. // recordUsageWithChannelPricing 用给定的渠道定价跑一次 RecordUsage,返回落库的 UsageLog。
  51. func recordUsageWithChannelPricing(t *testing.T, requestedModel string, subscriptionGroup bool, pricings []ChannelModelPricing) *UsageLog {
  52. t.Helper()
  53. const groupID = int64(777)
  54. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  55. svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
  56. cs := newChannelServiceWithPricings(groupID, pricings)
  57. svc.channelService = cs
  58. svc.resolver = NewModelPricingResolver(cs, svc.billingService)
  59. group := &Group{
  60. ID: groupID,
  61. Platform: PlatformOpenAI,
  62. RateMultiplier: 1,
  63. }
  64. if subscriptionGroup {
  65. group.SubscriptionType = SubscriptionTypeSubscription
  66. }
  67. err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
  68. Result: &OpenAIForwardResult{
  69. RequestID: "resp_luna_5256",
  70. Model: requestedModel,
  71. BillingModel: requestedModel,
  72. Usage: OpenAIUsage{
  73. InputTokens: 1_000_000,
  74. OutputTokens: 0,
  75. },
  76. Duration: time.Second,
  77. },
  78. ChannelUsageFields: ChannelUsageFields{
  79. OriginalModel: requestedModel,
  80. ChannelMappedModel: requestedModel,
  81. },
  82. APIKey: &APIKey{
  83. ID: 1,
  84. GroupID: i64p(groupID),
  85. Group: group,
  86. },
  87. User: &User{ID: 1},
  88. Account: &Account{ID: 1, Platform: PlatformOpenAI},
  89. })
  90. require.NoError(t, err)
  91. require.NotNil(t, usageRepo.lastLog)
  92. return usageRepo.lastLog
  93. }
  94. // 基线:请求模型与渠道定价 key 完全一致 → 按渠道价计。
  95. func TestChannelPricing_ExactModelMatch(t *testing.T) {
  96. log := recordUsageWithChannelPricing(t, "gpt-5.6-luna", false, []ChannelModelPricing{
  97. tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
  98. })
  99. require.InDelta(t, channelPricingExpectedChannelCost, log.InputCost, 1e-9)
  100. }
  101. // issue #5256 主回归:请求模型带 effort 后缀、渠道只配基名(无通配符)→ 仍应按渠道价计。
  102. // 修复前此处得到 0.2(官方兜底价)。
  103. func TestChannelPricing_SuffixedModelUsesNormalizedChannelPricing(t *testing.T) {
  104. log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-high", false, []ChannelModelPricing{
  105. tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
  106. })
  107. require.InDelta(t, channelPricingExpectedChannelCost, log.InputCost, 1e-9,
  108. "suffixed request model should fall back to the normalized channel pricing; got %v (%v = official fallback)",
  109. log.InputCost, channelPricingExpectedOfficialCost)
  110. }
  111. // 同一根因的另一种变体名:上游返回带日期后缀的模型名
  112. // (isCodexDateSuffix,如 gpt-5.6-luna-2026-08-01),渠道只配基名 → 仍应按渠道价计。
  113. func TestChannelPricing_DateSuffixedModelUsesNormalizedChannelPricing(t *testing.T) {
  114. log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-2026-08-01", false, []ChannelModelPricing{
  115. tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
  116. })
  117. require.InDelta(t, channelPricingExpectedChannelCost, log.InputCost, 1e-9,
  118. "date-suffixed request model should fall back to the normalized channel pricing; got %v", log.InputCost)
  119. }
  120. // 精确匹配优先:同时配了变体名与基名时,请求变体名必须命中变体的显式配价,
  121. // 不能被归一化后的基名覆盖。
  122. func TestChannelPricing_ExactVariantWinsOverNormalizedBaseName(t *testing.T) {
  123. log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-high", false, []ChannelModelPricing{
  124. tokenPricingForModels([]string{"gpt-5.6-luna-high"}, channelPricingUnrelatedCost),
  125. tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
  126. })
  127. require.InDelta(t, channelPricingUnrelatedCost, log.InputCost, 1e-9,
  128. "explicit per-variant channel pricing must win over the normalized base name")
  129. }
  130. // 订阅型分组走同一条渠道定价解析路径。
  131. func TestChannelPricing_SuffixedModelSubscriptionGroup(t *testing.T) {
  132. log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-high", true, []ChannelModelPricing{
  133. tokenPricingForModels([]string{"gpt-5.6-luna"}, channelPricingExpectedChannelCost),
  134. })
  135. require.InDelta(t, channelPricingExpectedChannelCost, log.InputCost, 1e-9)
  136. }
  137. // 反向保护:渠道只配了不相关的模型时,归一化查找不得误命中该配置,
  138. // 应落回官方兜底价。
  139. func TestChannelPricing_UnrelatedChannelModelNotMatched(t *testing.T) {
  140. log := recordUsageWithChannelPricing(t, "gpt-5.6-luna-high", false, []ChannelModelPricing{
  141. tokenPricingForModels([]string{"gpt-5.4"}, channelPricingUnrelatedCost),
  142. })
  143. require.InDelta(t, channelPricingExpectedOfficialCost, log.InputCost, 1e-9,
  144. "normalized lookup must not match an unrelated channel pricing entry")
  145. }