billing_context_schedule_test.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "math"
  6. "math/rand"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // scheduleScenario 阶梯表测试场景:同一份场景既做断言也做与计费函数的对账。
  13. type scheduleScenario struct {
  14. name string
  15. model string
  16. platform string
  17. groupPlatform string
  18. group *Group
  19. channel []ChannelModelPricing
  20. catalog *PricingService
  21. wantErr bool
  22. wantNil bool
  23. wantBasis ContextPricingBasis
  24. check func(t *testing.T, s *ContextPricingSchedule)
  25. }
  26. func enabledGroup(platform string) *Group {
  27. return &Group{ID: 100, Platform: platform, LongContextPricingEnabled: true}
  28. }
  29. func disabledGroup(platform string) *Group {
  30. return &Group{ID: 100, Platform: platform, LongContextPricingEnabled: false}
  31. }
  32. func sonnetChannel(iv ...PricingInterval) []ChannelModelPricing {
  33. return []ChannelModelPricing{{
  34. Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken,
  35. InputPrice: testPtrFloat64(2e-6), Intervals: iv,
  36. }}
  37. }
  38. func requireTier(t *testing.T, tier ContextPricingTier, min int, max *int, label string, input, output, cacheWrite, cacheRead *float64) {
  39. t.Helper()
  40. require.Equal(t, min, tier.MinTokens, "min")
  41. if max == nil {
  42. require.Nil(t, tier.MaxTokens, "max should be unbounded")
  43. } else {
  44. require.NotNil(t, tier.MaxTokens, "max")
  45. require.Equal(t, *max, *tier.MaxTokens, "max")
  46. }
  47. require.Equal(t, label, tier.Label, "label")
  48. requirePrice(t, input, tier.Input, "input")
  49. requirePrice(t, output, tier.Output, "output")
  50. requirePrice(t, cacheWrite, tier.CacheWrite, "cache_write")
  51. requirePrice(t, cacheRead, tier.CacheRead, "cache_read")
  52. }
  53. func requirePrice(t *testing.T, want, got *float64, field string) {
  54. t.Helper()
  55. if want == nil {
  56. require.Nil(t, got, field)
  57. return
  58. }
  59. require.NotNil(t, got, field)
  60. require.InDelta(t, *want, *got, 1e-15, field)
  61. }
  62. // mustCatalogFromJSON 走生产解析路径(含 above_XXXk 折算)构造目录 stub;场景表无 *testing.T,解析失败直接 panic。
  63. func mustCatalogFromJSON(body string) *PricingService {
  64. s := &PricingService{}
  65. data, err := s.parsePricingData([]byte(body))
  66. if err != nil {
  67. panic(err)
  68. }
  69. s.pricingData = data
  70. return s
  71. }
  72. func openAILadderCatalog() *PricingService { return mustCatalogFromJSON(openAILadderCatalogJSON) }
  73. func scheduleScenarios() []scheduleScenario {
  74. p := testPtrFloat64
  75. return []scheduleScenario{
  76. {
  77. name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
  78. group: enabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
  79. check: func(t *testing.T, s *ContextPricingSchedule) {
  80. require.Len(t, s.Tiers, 2)
  81. requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
  82. requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6))
  83. },
  84. },
  85. {
  86. name: "Grok 达到阈值即进高档", model: "grok-4.5", platform: PlatformGrok, groupPlatform: PlatformGrok,
  87. group: enabledGroup(PlatformGrok), wantBasis: ContextPricingBasisWholeRequest,
  88. check: func(t *testing.T, s *ContextPricingSchedule) {
  89. require.Len(t, s.Tiers, 2)
  90. requireTier(t, s.Tiers[0], 0, intPtr(199999), "<200K", p(2e-6), p(6e-6), nil, p(0.3e-6))
  91. requireTier(t, s.Tiers[1], 199999, nil, "≥200K", p(4e-6), p(12e-6), nil, p(0.6e-6))
  92. },
  93. },
  94. {
  95. name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
  96. group: disabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
  97. check: func(t *testing.T, s *ContextPricingSchedule) {
  98. require.Len(t, s.Tiers, 1)
  99. requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
  100. },
  101. },
  102. {
  103. name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI,
  104. group: nil, catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
  105. check: func(t *testing.T, s *ContextPricingSchedule) {
  106. require.Len(t, s.Tiers, 2)
  107. requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6))
  108. },
  109. },
  110. {
  111. name: "渠道倍率区间按渠道平价覆盖后的 base 折算(区间自定义标签不用于档位)", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  112. group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
  113. channel: sonnetChannel(
  114. PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)},
  115. PricingInterval{MinTokens: 200000, TierLabel: "long", InputMultiplier: p(2), OutputMultiplier: p(1.5), CacheWriteMultiplier: p(2), CacheReadMultiplier: p(2)},
  116. ),
  117. check: func(t *testing.T, s *ContextPricingSchedule) {
  118. require.Len(t, s.Tiers, 2)
  119. requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  120. requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(22.5e-6), p(7.5e-6), p(0.6e-6))
  121. },
  122. },
  123. {
  124. name: "区间显式价优先于倍率", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  125. group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
  126. channel: sonnetChannel(
  127. PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)},
  128. PricingInterval{MinTokens: 200000, InputPrice: p(9e-6), InputMultiplier: p(2)},
  129. ),
  130. check: func(t *testing.T, s *ContextPricingSchedule) {
  131. require.Len(t, s.Tiers, 2)
  132. requirePrice(t, p(9e-6), s.Tiers[1].Input, "input")
  133. },
  134. },
  135. {
  136. name: "区间空洞按 base 补档且同价段合并", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  137. group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
  138. channel: sonnetChannel(
  139. PricingInterval{MinTokens: 0, MaxTokens: intPtr(100000), InputMultiplier: p(0.5)},
  140. PricingInterval{MinTokens: 200000, InputMultiplier: p(2)},
  141. ),
  142. check: func(t *testing.T, s *ContextPricingSchedule) {
  143. require.Len(t, s.Tiers, 3)
  144. requireTier(t, s.Tiers[0], 0, intPtr(100000), "≤100K", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  145. requireTier(t, s.Tiers[1], 100000, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  146. requireTier(t, s.Tiers[2], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  147. },
  148. },
  149. {
  150. name: "首档同价空洞被合并", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  151. group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
  152. channel: sonnetChannel(
  153. PricingInterval{MinTokens: 0, MaxTokens: intPtr(100000), InputMultiplier: p(1)},
  154. PricingInterval{MinTokens: 200000, InputMultiplier: p(2)},
  155. ),
  156. check: func(t *testing.T, s *ContextPricingSchedule) {
  157. require.Len(t, s.Tiers, 2)
  158. requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  159. requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  160. },
  161. },
  162. {
  163. name: "末档有上限时尾段回落 base", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  164. group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
  165. channel: sonnetChannel(
  166. PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)},
  167. PricingInterval{MinTokens: 200000, MaxTokens: intPtr(1000000), InputMultiplier: p(2)},
  168. ),
  169. check: func(t *testing.T, s *ContextPricingSchedule) {
  170. require.Len(t, s.Tiers, 3)
  171. requireTier(t, s.Tiers[1], 200000, intPtr(1000000), "≤1M", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  172. requireTier(t, s.Tiers[2], 1000000, nil, ">1M", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  173. },
  174. },
  175. {
  176. name: "分组关闭时渠道区间折叠到 1-token 档", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  177. group: disabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
  178. channel: sonnetChannel(
  179. PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(0.5)},
  180. PricingInterval{MinTokens: 200000, InputMultiplier: p(2)},
  181. ),
  182. check: func(t *testing.T, s *ContextPricingSchedule) {
  183. require.Len(t, s.Tiers, 1)
  184. requireTier(t, s.Tiers[0], 0, nil, "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  185. },
  186. },
  187. {
  188. name: "分组关闭且首档不从 0 起时平价为 base", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  189. group: disabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
  190. channel: sonnetChannel(PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}),
  191. check: func(t *testing.T, s *ContextPricingSchedule) {
  192. require.Len(t, s.Tiers, 1)
  193. requirePrice(t, p(2e-6), s.Tiers[0].Input, "input")
  194. },
  195. },
  196. {
  197. name: "分组 token 价卡整张替换渠道定价并剥区间", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  198. group: &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
  199. Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
  200. Intervals: []PricingInterval{{MinTokens: 200000, InputMultiplier: p(5)}},
  201. }}},
  202. channel: sonnetChannel(PricingInterval{MinTokens: 200000, InputMultiplier: p(3)}),
  203. wantBasis: ContextPricingBasisWholeRequest,
  204. check: func(t *testing.T, s *ContextPricingSchedule) {
  205. require.Len(t, s.Tiers, 1)
  206. requireTier(t, s.Tiers[0], 0, nil, "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
  207. },
  208. },
  209. {
  210. name: "分组价卡之上叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
  211. group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
  212. Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
  213. }}},
  214. catalog: openAILadderCatalog(),
  215. wantBasis: ContextPricingBasisWholeRequest,
  216. check: func(t *testing.T, s *ContextPricingSchedule) {
  217. require.Len(t, s.Tiers, 2)
  218. requirePrice(t, p(1e-6), s.Tiers[0].Input, "input")
  219. requirePrice(t, p(2e-6), s.Tiers[1].Input, "input")
  220. requirePrice(t, p(22.5e-6), s.Tiers[1].Output, "output")
  221. },
  222. },
  223. {
  224. name: "Gemini 目录阶梯整单换档", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
  225. group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
  226. check: func(t *testing.T, s *ContextPricingSchedule) {
  227. require.Len(t, s.Tiers, 2)
  228. // 缓存写入按标准输入价,与 input 一同整单换档
  229. requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), p(1.25e-6), p(0.125e-6))
  230. requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
  231. },
  232. },
  233. {
  234. name: "Gemini 分组关闭时无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
  235. group: disabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
  236. check: func(t *testing.T, s *ContextPricingSchedule) {
  237. require.Len(t, s.Tiers, 1)
  238. },
  239. },
  240. {
  241. name: "Gemini 渠道平价之上叠加目录阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
  242. group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON),
  243. channel: []ChannelModelPricing{{
  244. Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6),
  245. }},
  246. wantBasis: ContextPricingBasisWholeRequest,
  247. check: func(t *testing.T, s *ContextPricingSchedule) {
  248. require.Len(t, s.Tiers, 2)
  249. requirePrice(t, p(3e-6), s.Tiers[0].Input, "input")
  250. requirePrice(t, p(6e-6), s.Tiers[1].Input, "input")
  251. },
  252. },
  253. {
  254. name: "Gemini 目录无阶梯字段时开关开启也无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
  255. group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
  256. check: func(t *testing.T, s *ContextPricingSchedule) {
  257. require.Len(t, s.Tiers, 1)
  258. },
  259. },
  260. {
  261. name: "Gemini 官方参考价与目录数据同源", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini,
  262. group: nil, catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
  263. check: func(t *testing.T, s *ContextPricingSchedule) {
  264. require.Len(t, s.Tiers, 2)
  265. },
  266. },
  267. {
  268. name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite,
  269. group: enabledGroup(PlatformComposite),
  270. channel: []ChannelModelPricing{{
  271. Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
  272. }},
  273. catalog: openAILadderCatalog(),
  274. wantBasis: ContextPricingBasisWholeRequest,
  275. check: func(t *testing.T, s *ContextPricingSchedule) {
  276. require.Len(t, s.Tiers, 2)
  277. requirePrice(t, p(1e-6), s.Tiers[0].Input, "input")
  278. requirePrice(t, p(2e-6), s.Tiers[1].Input, "input")
  279. },
  280. },
  281. {
  282. name: "渠道显式 cache_write=0 保留为 0", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
  283. group: enabledGroup(PlatformAnthropic),
  284. channel: []ChannelModelPricing{{
  285. Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken,
  286. InputPrice: p(2e-6), CacheWritePrice: p(0),
  287. }},
  288. wantBasis: ContextPricingBasisWholeRequest,
  289. check: func(t *testing.T, s *ContextPricingSchedule) {
  290. require.Len(t, s.Tiers, 1)
  291. require.NotNil(t, s.Tiers[0].CacheWrite)
  292. require.Zero(t, *s.Tiers[0].CacheWrite)
  293. },
  294. },
  295. {
  296. name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
  297. group: enabledGroup(PlatformOpenAI),
  298. catalog: mustCatalogFromJSON(`{"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat",
  299. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, "cache_read_input_token_cost": 5e-07,
  300. "input_cost_per_token_above_272k_tokens": 1e-05,
  301. "output_cost_per_token_above_272k_tokens": 4.5e-05,
  302. "cache_read_input_token_cost_above_272k_tokens": 1e-06}}`),
  303. wantBasis: ContextPricingBasisWholeRequest,
  304. check: func(t *testing.T, s *ContextPricingSchedule) {
  305. require.Len(t, s.Tiers, 2)
  306. requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(5e-6), p(30e-6), p(6.25e-6), p(0.5e-6))
  307. requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(10e-6), p(45e-6), p(12.5e-6), p(1e-6))
  308. },
  309. },
  310. {
  311. name: "图片模式返回 nil", model: "gpt-image-2", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
  312. group: enabledGroup(PlatformOpenAI),
  313. channel: []ChannelModelPricing{{
  314. Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage, PerRequestPrice: p(0.04),
  315. }},
  316. wantNil: true,
  317. },
  318. {
  319. name: "无任何定价来源报错", model: "unknown-model-xyz", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
  320. group: enabledGroup(PlatformOpenAI), wantErr: true,
  321. },
  322. }
  323. }
  324. func newScheduleTestEnv(t *testing.T, sc scheduleScenario) (*BillingService, *ModelPricingResolver) {
  325. t.Helper()
  326. return newTokenCostTestEnv(t, sc.groupPlatform, sc.channel, sc.catalog)
  327. }
  328. func TestResolveContextPricingSchedule_Scenarios(t *testing.T) {
  329. for _, sc := range scheduleScenarios() {
  330. t.Run(sc.name, func(t *testing.T) {
  331. bs, resolver := newScheduleTestEnv(t, sc)
  332. sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
  333. Model: sc.model, Group: sc.group, Platform: sc.platform,
  334. })
  335. if sc.wantErr {
  336. require.Error(t, err)
  337. require.Nil(t, sched)
  338. return
  339. }
  340. require.NoError(t, err)
  341. if sc.wantNil {
  342. require.Nil(t, sched)
  343. return
  344. }
  345. require.NotNil(t, sched)
  346. require.Equal(t, sc.wantBasis, sched.Basis)
  347. require.NotEmpty(t, sched.Tiers)
  348. require.Zero(t, sched.Tiers[0].MinTokens, "首档从 0 起")
  349. for i := 1; i < len(sched.Tiers); i++ {
  350. require.NotNil(t, sched.Tiers[i-1].MaxTokens)
  351. require.Equal(t, *sched.Tiers[i-1].MaxTokens, sched.Tiers[i].MinTokens, "档位连续")
  352. require.Less(t, sched.Tiers[i-1].MinTokens, sched.Tiers[i].MinTokens, "档位按上下文升序")
  353. }
  354. if len(sched.Tiers) > 1 {
  355. for i, tier := range sched.Tiers {
  356. require.NotEmpty(t, tier.Label, "多档时每档都有标签 #%d", i)
  357. }
  358. require.Nil(t, sched.Tiers[len(sched.Tiers)-1].MaxTokens, "末档无上限")
  359. }
  360. if sc.check != nil {
  361. sc.check(t, sched)
  362. }
  363. })
  364. }
  365. }
  366. func TestResolveContextPricingSchedule_NilResolver(t *testing.T) {
  367. bs := NewBillingService(&config.Config{}, nil)
  368. sched, err := bs.ResolveContextPricingSchedule(context.Background(), nil, ContextPricingScheduleInput{Model: "gpt-5.4"})
  369. require.Error(t, err)
  370. require.Nil(t, sched)
  371. }
  372. func TestPlazaSchedulePreservesCacheWrite1h(t *testing.T) {
  373. cp := sonnetChannel(PricingInterval{MinTokens: 272000, CacheWriteMultiplier: testPtrFloat64(2)})
  374. cp[0].CacheWritePrice = testPtrFloat64(12.5e-6)
  375. cp[0].CacheWrite1hPrice = testPtrFloat64(20e-6)
  376. group := enabledGroup(PlatformAnthropic)
  377. bs, resolver := newScheduleTestEnv(t, scheduleScenario{channel: cp, groupPlatform: PlatformAnthropic})
  378. sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic})
  379. require.NoError(t, err)
  380. display := plazaPricingFromSchedule(&cp[0], sched)
  381. requirePrice(t, testPtrFloat64(20e-6), display.CacheWrite1hPrice, "base 1h")
  382. require.Len(t, display.Intervals, 2)
  383. requirePrice(t, testPtrFloat64(25e-6), display.Intervals[1].CacheWritePrice, "tier 5m")
  384. requirePrice(t, testPtrFloat64(40e-6), display.Intervals[1].CacheWrite1hPrice, "tier 1h")
  385. a := ContextPricingTier{CacheWrite1h: testPtrFloat64(1)}
  386. b := ContextPricingTier{CacheWrite1h: testPtrFloat64(2)}
  387. require.Len(t, mergeEqualContextTiers([]ContextPricingTier{a, b}), 2)
  388. }
  389. // --- 对账:阶梯表推算的费用必须等于真实计费函数 ---
  390. type tokenKind int
  391. const (
  392. kindInput tokenKind = iota
  393. kindOutput
  394. kindCacheRead
  395. kindCacheWrite
  396. )
  397. func tierPrice(tier ContextPricingTier, kind tokenKind) float64 {
  398. var p *float64
  399. switch kind {
  400. case kindInput:
  401. p = tier.Input
  402. case kindOutput:
  403. p = tier.Output
  404. case kindCacheRead:
  405. p = tier.CacheRead
  406. case kindCacheWrite:
  407. p = tier.CacheWrite
  408. }
  409. if p == nil {
  410. return 0
  411. }
  412. return *p
  413. }
  414. func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier {
  415. for _, tier := range tiers {
  416. if contextTokens > tier.MinTokens && (tier.MaxTokens == nil || contextTokens <= *tier.MaxTokens) {
  417. return tier
  418. }
  419. }
  420. return tiers[len(tiers)-1]
  421. }
  422. // expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用:
  423. // 整单基准取所在档单价 × 全量。
  424. func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 {
  425. return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind)
  426. }
  427. func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) {
  428. const outputProbe = 1000
  429. rng := rand.New(rand.NewSource(20260823))
  430. for _, sc := range scheduleScenarios() {
  431. if sc.wantErr || sc.wantNil {
  432. continue
  433. }
  434. t.Run(sc.name, func(t *testing.T) {
  435. bs, resolver := newScheduleTestEnv(t, sc)
  436. ctx := context.Background()
  437. if sc.platform != "" {
  438. ctx = WithResolvedTargetPlatform(ctx, sc.platform)
  439. }
  440. sched, err := bs.ResolveContextPricingSchedule(ctx, resolver, ContextPricingScheduleInput{
  441. Model: sc.model, Group: sc.group, Platform: sc.platform,
  442. })
  443. require.NoError(t, err)
  444. require.NotNil(t, sched)
  445. // 消费方视角重建同一份计费请求(与网关一致)。
  446. pricingInput := PricingInput{Model: sc.model, Group: sc.group}
  447. if sc.group != nil {
  448. gid := sc.group.ID
  449. pricingInput.GroupID = &gid
  450. }
  451. resolved := resolver.Resolve(ctx, pricingInput)
  452. cost := func(tokens UsageTokens) float64 {
  453. bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  454. Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1,
  455. Resolver: resolver, Resolved: resolved,
  456. })
  457. require.NoError(t, err)
  458. return bd.ActualCost
  459. }
  460. probes := []int{1, 2, 999, 1000, 1001, 10_000_000}
  461. for _, tier := range sched.Tiers {
  462. for _, b := range []*int{tier.MaxTokens} {
  463. if b == nil {
  464. continue
  465. }
  466. probes = append(probes, *b-1, *b, *b+1)
  467. }
  468. }
  469. for i := 0; i < 8; i++ {
  470. probes = append(probes, 1+rng.Intn(3_000_000))
  471. }
  472. for _, c := range probes {
  473. if c <= 0 {
  474. continue
  475. }
  476. assertCostClose(t, expectedCostFromSchedule(sched, kindInput, c), cost(UsageTokens{InputTokens: c}), "input@%d", c)
  477. assertCostClose(t, expectedCostFromSchedule(sched, kindCacheRead, c), cost(UsageTokens{CacheReadTokens: c}), "cache_read@%d", c)
  478. assertCostClose(t, expectedCostFromSchedule(sched, kindCacheWrite, c), cost(UsageTokens{CacheCreationTokens: c}), "cache_write@%d", c)
  479. wantOutput := float64(outputProbe) * tierPrice(tierAt(sched.Tiers, c), kindOutput)
  480. gotOutput := cost(UsageTokens{InputTokens: c, OutputTokens: outputProbe}) - cost(UsageTokens{InputTokens: c})
  481. assertCostClose(t, wantOutput, gotOutput, "output@%d", c)
  482. }
  483. })
  484. }
  485. }
  486. func assertCostClose(t *testing.T, want, got float64, format string, args ...any) {
  487. t.Helper()
  488. tolerance := 1e-12 + 1e-9*math.Abs(want)
  489. require.InDeltaf(t, want, got, tolerance, format, args...)
  490. }
  491. func sonnetChannelWithTimePricing(tp *ChannelTimePricing) []ChannelModelPricing {
  492. return []ChannelModelPricing{{
  493. Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken,
  494. InputPrice: testPtrFloat64(2e-6), TimePricing: tp,
  495. }}
  496. }
  497. func TestResolveContextPricingSchedule_TimePricing(t *testing.T) {
  498. valid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{
  499. {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2},
  500. {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
  501. {StartTime: "12:00", EndTime: "13:00", Multiplier: 1},
  502. }}
  503. t.Run("渠道分时按开始时间升序列出且跳过倍率 1 的时段", func(t *testing.T) {
  504. bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil)
  505. sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
  506. Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
  507. })
  508. require.NoError(t, err)
  509. require.NotNil(t, sched.TimePricing)
  510. require.Equal(t, "Asia/Shanghai", sched.TimePricing.Timezone)
  511. require.False(t, sched.TimePricing.WeekdaysOnly)
  512. require.Equal(t, []TimePricingPeriod{
  513. {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
  514. {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2},
  515. }, sched.TimePricing.Periods)
  516. // 阶梯表单价是标准时段价,不含分时倍率
  517. requirePrice(t, testPtrFloat64(2e-6), sched.Tiers[0].Input, "input")
  518. // 对账:时段内的真实计费 = 标准单价 × token × 倍率
  519. group := enabledGroup(PlatformAnthropic)
  520. gid := group.ID
  521. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group})
  522. loc, err := time.LoadLocation("Asia/Shanghai")
  523. require.NoError(t, err)
  524. for _, tc := range []struct {
  525. at time.Time
  526. want float64
  527. }{
  528. {time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 0.5},
  529. {time.Date(2026, 8, 23, 12, 30, 0, 0, loc), 1},
  530. {time.Date(2026, 8, 23, 21, 59, 59, 0, loc), 1.2},
  531. {time.Date(2026, 8, 23, 22, 0, 0, 0, loc), 1},
  532. } {
  533. cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  534. Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000},
  535. RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved,
  536. })
  537. require.NoError(t, err)
  538. assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at)
  539. }
  540. })
  541. t.Run("仅工作日配置透传标注且时段仍列出", func(t *testing.T) {
  542. weekdays := &ChannelTimePricing{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []ChannelTimePricingPeriod{
  543. {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
  544. }}
  545. bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(weekdays), nil)
  546. sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
  547. Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
  548. })
  549. require.NoError(t, err)
  550. require.NotNil(t, sched.TimePricing, "探针锚点必须落在工作日,否则仅工作日配置的时段会被整组剔除")
  551. require.True(t, sched.TimePricing.WeekdaysOnly)
  552. require.Equal(t, []TimePricingPeriod{
  553. {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
  554. }, sched.TimePricing.Periods)
  555. // 对账:工作日时段内乘倍率,周末同一时段按标准价
  556. group := enabledGroup(PlatformAnthropic)
  557. gid := group.ID
  558. resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group})
  559. loc, err := time.LoadLocation("Asia/Shanghai")
  560. require.NoError(t, err)
  561. for _, tc := range []struct {
  562. at time.Time
  563. want float64
  564. }{
  565. {time.Date(2026, 8, 24, 3, 0, 0, 0, loc), 0.5}, // 周一
  566. {time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 1}, // 周日
  567. } {
  568. cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
  569. Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000},
  570. RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved,
  571. })
  572. require.NoError(t, err)
  573. assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at)
  574. }
  575. })
  576. t.Run("配置非法时计费按 1 计,阶梯表不列分时", func(t *testing.T) {
  577. invalid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{
  578. {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
  579. {StartTime: "08:00", EndTime: "09:00", Multiplier: 0.8}, // 与上一段重叠
  580. }}
  581. bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(invalid), nil)
  582. sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
  583. Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
  584. })
  585. require.NoError(t, err)
  586. require.Nil(t, sched.TimePricing)
  587. })
  588. t.Run("分组价卡覆盖后渠道分时不再生效", func(t *testing.T) {
  589. bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil)
  590. group := &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
  591. Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6),
  592. }}}
  593. sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
  594. Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic,
  595. })
  596. require.NoError(t, err)
  597. require.Nil(t, sched.TimePricing)
  598. })
  599. t.Run("无分时配置为 nil", func(t *testing.T) {
  600. bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(nil), nil)
  601. sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
  602. Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
  603. })
  604. require.NoError(t, err)
  605. require.Nil(t, sched.TimePricing)
  606. })
  607. }